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:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
+18 -14
View File
@@ -22,20 +22,22 @@
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
<div id="top">
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> August 20, 2026 · <em>v2.0</em></p>
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, you may optionally sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains both paths.</p>
<p><strong>Last updated:</strong> August 22, 2026 · <em>v2.0</em></p>
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, first-run onboarding includes a limited anonymous AI trial, and you may later sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains these options.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. With a user-configured cloud engine, recordings are sent directly to that ASR provider. If you explicitly choose managed credits, recordings are sent through the OSGKeyboard managed gateway at <code>account.osglab.com</code> to the managed ASR provider for transcription. OSGKeyboard does not keep raw recordings after the request is processed.</li>
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you use a configured LLM or managed credits. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
<li><strong>Speaking-style learning</strong> — only after you tap “Generate My Style,” eligible pairs of pre-polish and final text, together with the polish-style prompts that produced those examples, are sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. 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)"
)
+109 -43
View File
@@ -8,42 +8,71 @@
import Foundation
import OSGKeyboardShared
enum AIHintRefreshOutcome: Equatable {
case skippedFresh
case completed(updatedLocales: [String])
}
@MainActor
enum AIHintRefreshService {
private static var inFlight: Task<Void, Never>?
final class AIHintRefreshService {
static let shared = AIHintRefreshService()
private let transport: any PublicContentHTTPTransport
private let defaults: UserDefaults?
private let baseURL: URL
private var inFlight: Task<AIHintRefreshOutcome, Never>?
init(
transport: any PublicContentHTTPTransport = URLSessionPublicContentHTTPTransport(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable,
baseURL: URL = AIHintFeedEndpoints.baseURL
) {
self.transport = transport
self.defaults = defaults
self.baseURL = baseURL
}
static func refreshIfNeeded(reason: String) {
guard AIHintStore.shouldRefresh() else {
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
return
}
shared.scheduleRefreshIfNeeded(reason: reason)
}
func scheduleRefreshIfNeeded(reason: String) {
guard inFlight == nil else {
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
return
}
inFlight = Task {
defer { inFlight = nil }
await runRefresh(reason: reason)
return await refreshNowIfNeeded(reason: reason)
}
}
private static func runRefresh(reason: String) async {
AIHintStore.markAttempt()
func refreshNowIfNeeded(
reason: String,
now: Date = Date(),
force: Bool = false
) async -> AIHintRefreshOutcome {
if !force, !AIHintStore.shouldRefresh(now: now, defaults: defaults) {
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
return .skippedFresh
}
AIHintStore.markAttempt(defaults: defaults)
OSGDiag.log("AIHintRefresh start reason=\(reason)", category: "hints")
// The manifest only supplies fallback dates, so a manifest failure must
// not stop the packs, and one locale's failure must not stop the other.
let manifest = try? await fetchManifest()
let manifest = (try? await fetchManifest()) ?? AIHintStore.loadManifest(defaults: defaults)
var updatedLocales: [String] = []
for locale in AIHintFeedEndpoints.supportedLocales {
if Task.isCancelled { return }
guard AIHintStore.shouldRefresh(locale: locale) else { continue }
if Task.isCancelled { break }
if !force,
!AIHintStore.shouldRefresh(locale: locale, now: now, defaults: defaults) {
continue
}
do {
let pack = try await readyPack(locale: locale, manifest: manifest)
AIHintStore.saveReadyPack(pack)
OSGDiag.log(
"AIHintRefresh wrote locale=\(locale) cards=\(pack.cards.count)",
category: "hints"
)
try await refreshPack(locale: locale, manifest: manifest, now: now)
updatedLocales.append(locale)
} catch {
// Strategy B: keep this locale's previous successful ready pack.
OSGDiag.log(
@@ -53,31 +82,67 @@ enum AIHintRefreshService {
)
}
}
return .completed(updatedLocales: updatedLocales)
}
private static func readyPack(
private func refreshPack(
locale: String,
manifest: AIHintManifest?
) async throws -> AIHintPack {
let remote = try await fetchPack(locale: locale)
manifest: AIHintManifest?,
now: Date
) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent(locale))
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let etag = AIHintStore.packETag(locale: locale, defaults: defaults) {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await transport.data(for: request)
if response.statusCode == 304 {
guard var existing = AIHintStore.loadReadyPack(locale: locale, defaults: defaults) else {
throw PublicContentHTTPError.notModifiedWithoutCache
}
existing.refreshedAt = now
AIHintStore.saveReadyPack(existing, defaults: defaults)
if let etag = response.value(forHTTPHeaderField: "ETag") {
AIHintStore.setPackETag(etag, locale: locale, defaults: defaults)
}
OSGDiag.log("AIHintRefresh kept locale=\(locale) (304)", category: "hints")
return
}
guard response.statusCode == 200 else {
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
}
let remote = try JSONDecoder().decode(AIHintPack.self, from: data)
let filtered = remote.cards.filter { card in
let hay = card.displayText + card.prompt
return !hay.contains("历史上的今天")
&& !hay.localizedCaseInsensitiveContains("on this day")
}
// Prefer remote clipboard cards when present; always keep local evergreen.
let merged = merge(remote: filtered, locale: locale)
let merged = Self.merge(remote: filtered, locale: locale)
let compressed = await AIHintKeywordCompressor().compress(
cards: merged,
locale: locale
)
return AIHintPack(
let ready = AIHintPack(
locale: locale,
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
version: max(remote.version, 1),
cards: compressed,
refreshedAt: Date()
refreshedAt: now
)
AIHintStore.saveReadyPack(ready, defaults: defaults)
AIHintStore.setPackETag(
response.value(forHTTPHeaderField: "ETag"),
locale: locale,
defaults: defaults
)
OSGDiag.log(
"AIHintRefresh wrote locale=\(locale) cards=\(ready.cards.count)",
category: "hints"
)
}
@@ -103,25 +168,26 @@ enum AIHintRefreshService {
return Array(byID.values).sorted { $0.priority > $1.priority }
}
private static func fetchManifest() async throws -> AIHintManifest {
let (data, response) = try await URLSession.shared.data(from: AIHintFeedEndpoints.manifestURL)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintManifest.self, from: data)
}
private static func fetchPack(locale: String) async throws -> AIHintPack {
let url = AIHintFeedEndpoints.packURL(locale: locale)
let (data, response) = try await URLSession.shared.data(from: url)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintPack.self, from: data)
}
private static func validateHTTP(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
private func fetchManifest() async throws -> AIHintManifest? {
var request = URLRequest(url: baseURL.appendingPathComponent("manifest"))
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let etag = AIHintStore.manifestETag(defaults: defaults) {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
guard (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
let (data, response) = try await transport.data(for: request)
if response.statusCode == 304 {
return AIHintStore.loadManifest(defaults: defaults)
}
guard response.statusCode == 200 else {
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
}
let manifest = try JSONDecoder().decode(AIHintManifest.self, from: data)
AIHintStore.saveManifest(
manifest,
etag: response.value(forHTTPHeaderField: "ETag"),
defaults: defaults
)
return manifest
}
}
@@ -48,16 +48,33 @@ struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
func bearerToken() async throws -> String? {
guard try await apiClient.currentSession() != nil else { return nil }
return try await apiClient.accessTokenForAuthorizedRequest()
do {
return try await apiClient.accessTokenForAuthorizedRequest()
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
guard let failedToken, !failedToken.isEmpty else { return nil }
return try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: failedToken
)
do {
return try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: failedToken
)
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
private static func isInvalidSession(_ error: AccountAPIError) -> Bool {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
return true
default:
return false
}
}
}
@@ -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)
}
}
+103 -26
View File
@@ -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,
+74 -61
View File
@@ -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) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
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,24 +431,24 @@ struct AIAgentSkillsView: View {
}
private func skillDescription(_ skill: AIClipboardSkill) -> String {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
if skill.isUserCreated {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
}
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
private func handleCardTap(_ skill: AIClipboardSkill) {
if 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)
}
addOrWarn(skill)
}
private func handleEdit(_ skill: AIClipboardSkill) {
@@ -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 {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
if skill.isUserCreated {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
}
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
@@ -3,7 +3,7 @@
//
// Interactive layout preview and What's New recording host for AI Agent
// clipboard skills on the real unified `AIKeyboardView`. Launch with
// `--ai-skills-demo`, optional `--skills-count=N` (1...8), and optional
// `--ai-skills-demo`, optional `--skills-count=N`, and optional
// `--whats-new-lang=zh|en`.
#if DEBUG
@@ -61,7 +61,7 @@ struct AIClipboardSkillLayoutDemoView: View {
.foregroundStyle(.white)
.frame(minWidth: 72)
Button {
count = min(8, count + 1)
count = min(AIClipboardSkillCatalog.catalog.count, count + 1)
} label: {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
@@ -69,7 +69,7 @@ struct AIClipboardSkillLayoutDemoView: View {
}
.foregroundStyle(.green)
HStack(spacing: 8) {
ForEach([3, 5, 8], id: \.self) { n in
ForEach([3, 8, AIClipboardSkillCatalog.catalog.count], id: \.self) { n in
Button("\(n)") {
count = n
}
@@ -103,7 +103,7 @@ struct AIClipboardSkillLayoutDemoView: View {
}
private func showSkills(_ count: Int) {
let clamped = min(max(count, 1), 8)
let clamped = min(max(count, 1), AIClipboardSkillCatalog.catalog.count)
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
state.enabledClipboardSkillIDs = Array(
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
@@ -135,7 +135,7 @@ struct AIClipboardSkillLayoutDemoView: View {
let prefix = "--skills-count="
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
let value = Int(arg.dropFirst(prefix.count)),
(1...8).contains(value) {
(1...AIClipboardSkillCatalog.catalog.count).contains(value) {
return value
}
return 5
@@ -19,6 +19,7 @@ struct AccountAppleAuthorizationButton: View {
@EnvironmentObject private var coordinator: AccountSessionCoordinator
let purpose: AccountAppleAuthorizationPurpose
var onSignedIn: () -> Void = {}
var onDeleted: () -> Void = {}
@State private var rawNonce: String?
@@ -30,7 +31,8 @@ struct AccountAppleAuthorizationButton: View {
onCompletion: completeAuthorization
)
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(maxWidth: .infinity, minHeight: 50)
.frame(maxWidth: .infinity)
.frame(height: 50)
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.accessibilityLabel(
purpose == .signIn
@@ -85,6 +87,7 @@ struct AccountAppleAuthorizationButton: View {
await coordinator.signIn(with: payload)
if coordinator.isSignedIn {
ProviderConfig.shared.reloadFromPersistedStorage()
onSignedIn()
}
case .deleteAccount:
await coordinator.deleteAccount(with: payload)
@@ -14,7 +14,11 @@ struct AccountCenterUITestHarness: View {
@StateObject private var coordinator: AccountSessionCoordinator
init() {
let service = AccountCenterUITestService()
let service = AccountCenterUITestService(
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
"--account-snapshot-failure"
)
)
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
_coordinator = StateObject(
wrappedValue: AccountSessionCoordinator(
@@ -91,6 +95,11 @@ private actor AccountCenterUITestService:
createdAtEpochSeconds: 1_700_000_000,
displayName: "UI Test Account"
)
private let shouldFailSnapshot: Bool
init(shouldFailSnapshot: Bool = false) {
self.shouldFailSnapshot = shouldFailSnapshot
}
func restoreSession() async throws -> AccountSession? {
account
@@ -108,7 +117,10 @@ private actor AccountCenterUITestService:
}
func loadAccountCenter() async throws -> AccountCenterSnapshot {
AccountCenterSnapshot(
if shouldFailSnapshot {
throw AccountCenterUITestError.snapshotUnavailable
}
return AccountCenterSnapshot(
account: account,
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
referrals: []
@@ -180,6 +192,10 @@ private actor AccountCenterUITestService:
}
}
private enum AccountCenterUITestError: Error {
case snapshotUnavailable
}
@MainActor
private final class AccountCenterUITestCreditStore: AccountCreditStore {
private let accountID: UUID
@@ -32,8 +32,10 @@ struct AccountCenterView: View {
coordinator.creditPurchases.reset()
return
}
await coordinator.refreshAccountData()
await coordinator.creditPurchases.prepare(accountID: accountID)
async let accountRefresh: Void = coordinator.refreshAccountData()
async let purchasePreparation: Void =
coordinator.creditPurchases.prepare(accountID: accountID)
_ = await (accountRefresh, purchasePreparation)
if case let .signedIn(session) = coordinator.sessionPhase {
displayNameDraft = session.displayName ?? ""
}
@@ -148,7 +150,10 @@ struct AccountCenterView: View {
.padding(.bottom, Spacing.lg)
}
.refreshable {
await coordinator.refreshAccountData(force: true)
async let accountRefresh: Void = coordinator.refreshAccountData(force: true)
async let catalogRefresh: Void =
coordinator.creditPurchases.refreshCatalog(accountID: session.accountID)
_ = await (accountRefresh, catalogRefresh)
}
.accessibilityIdentifier("account.center.signedIn")
}
@@ -171,17 +176,19 @@ struct AccountCenterView: View {
Task { await coordinator.refreshAccountData(force: true) }
}
case .loaded:
CardSection("account.storekit.section") {
VStack(spacing: Spacing.sm) {
AccountCreditPurchaseSection(
manager: coordinator.creditPurchases,
accountID: session.accountID,
onGranted: {
Task { await coordinator.refreshAccountData(force: true) }
}
)
purchaseHistoryLink(accountID: session.accountID)
}
EmptyView()
}
CardSection("account.storekit.section") {
VStack(spacing: Spacing.sm) {
AccountCreditPurchaseSection(
manager: coordinator.creditPurchases,
accountID: session.accountID,
onGranted: {
Task { await coordinator.refreshAccountData(force: true) }
}
)
purchaseHistoryLink(accountID: session.accountID)
}
}
}
@@ -522,7 +529,7 @@ private struct AccountCreditPurchaseSection: View {
}
}
.surfaceCard()
} else if manager.state == .loading {
} else if manager.catalogPhase == .loading {
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
@@ -535,6 +542,7 @@ private struct AccountCreditPurchaseSection: View {
.surfaceCard()
}
catalogStateMessage
stateMessage
}
.onChange(of: manager.lastGrantedBalance) { previous, current in
@@ -613,6 +621,37 @@ private struct AccountCreditPurchaseSection: View {
return productID
}
@ViewBuilder
private var catalogStateMessage: some View {
switch manager.catalogPhase {
case .idle:
catalogRetryMessage(messageKey: "account.storekit.error.productUnavailable")
case .loading, .loaded:
if let messageKey = manager.catalogRefreshErrorKey {
catalogRetryMessage(messageKey: messageKey)
}
case .failed(let messageKey):
catalogRetryMessage(messageKey: messageKey)
}
}
private func catalogRetryMessage(messageKey: String) -> some View {
HStack(spacing: Spacing.sm) {
Label(
LocalizedStringKey(messageKey),
systemImage: "exclamationmark.triangle.fill"
)
.font(TypeStyle.footnote)
.foregroundStyle(palette.danger)
Spacer(minLength: Spacing.xs)
Button("account.retry") {
Task { await manager.refreshCatalog(accountID: accountID) }
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent)
}
}
@ViewBuilder
private var stateMessage: some View {
Group {
@@ -646,11 +685,6 @@ private struct AccountCreditPurchaseSection: View {
.font(TypeStyle.footnote)
.foregroundStyle(palette.danger)
Spacer(minLength: Spacing.xs)
Button("account.retry") {
Task { await manager.prepare(accountID: accountID) }
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent)
}
default:
EmptyView()
@@ -21,13 +21,19 @@ struct AccountCreditPurchaseOption: Identifiable, Equatable {
final class AccountCreditPurchaseManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case purchasing(productID: String)
case pending
case succeeded(credits: Int64)
case failed(messageKey: String)
}
enum CatalogPhase: Equatable {
case idle
case loading
case loaded
case failed(messageKey: String)
}
enum HistoryPhase: Equatable {
case idle
case loading
@@ -36,7 +42,10 @@ final class AccountCreditPurchaseManager: ObservableObject {
}
@Published private(set) var state: State = .idle
@Published private(set) var catalogPhase: CatalogPhase = .idle
@Published private(set) var options: [AccountCreditPurchaseOption] = []
@Published private(set) var isRefreshingCatalog = false
@Published private(set) var catalogRefreshErrorKey: String?
@Published private(set) var lastGrantedBalance: Int64?
@Published private(set) var historyPhase: HistoryPhase = .idle
@Published private(set) var isLoadingMoreHistory = false
@@ -45,80 +54,192 @@ final class AccountCreditPurchaseManager: ObservableObject {
private let service: any AccountCenterServicing
private let store: any AccountCreditStore
private let analyticsClient: any AnalyticsClient
private let successMessageDuration: Duration
private var prepareTask: Task<Void, Never>?
private var prepareRequestID: UUID?
private var updatesTask: Task<Void, Never>?
private var successDismissTask: Task<Void, Never>?
private var activeAccountID: UUID?
private var sessionID: UUID?
private var pendingProductID: String?
private var configuredProductIDs: Set<String> = []
private var nextHistoryCursor: String?
init(
service: any AccountCenterServicing,
store: any AccountCreditStore = LiveAccountCreditStore(),
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
successMessageDuration: Duration = .seconds(4)
) {
self.service = service
self.store = store
self.analyticsClient = analyticsClient
self.successMessageDuration = successMessageDuration
}
deinit {
prepareTask?.cancel()
updatesTask?.cancel()
successDismissTask?.cancel()
}
func prepare(accountID: UUID) async {
guard activeAccountID != accountID || options.isEmpty else { return }
/// Starts one non-blocking StoreKit preparation for a signed-in account.
/// Repeated calls for the same account preserve the current session state.
func startSession(accountID: UUID) {
guard activeAccountID != accountID else { return }
reset(for: accountID)
state = .loading
beginPrepare(accountID: accountID)
}
func endSession() {
reset()
}
func prepare(accountID: UUID, force: Bool = false) async {
if activeAccountID != accountID {
startSession(accountID: accountID)
}
if let prepareTask {
await prepareTask.value
return
}
if !force, catalogPhase == .loaded, !options.isEmpty {
return
}
beginPrepare(accountID: accountID)
await prepareTask?.value
}
func refreshCatalog(accountID: UUID) async {
await prepare(accountID: accountID, force: true)
}
private func beginPrepare(accountID: UUID) {
guard activeAccountID == accountID, prepareTask == nil, let sessionID else { return }
let requestID = UUID()
prepareRequestID = requestID
catalogRefreshErrorKey = nil
isRefreshingCatalog = true
if options.isEmpty {
catalogPhase = .loading
}
prepareTask = Task { @MainActor [weak self] in
await self?.performPrepare(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
)
}
}
private func performPrepare(
accountID: UUID,
sessionID: UUID,
requestID: UUID
) async {
defer { finishPrepare(requestID: requestID) }
do {
try Task.checkCancellation()
let catalog = try await service.loadCreditProducts()
configuredProductIDs = Set(catalog.map(\.productID))
var loadedOptions: [AccountCreditPurchaseOption] = []
for creditProduct in catalog {
guard let product = try await store.product(for: creditProduct.productID) else {
continue
}
loadedOptions.append(
AccountCreditPurchaseOption(
productID: creditProduct.productID,
credits: creditProduct.credits,
displayPrice: product.displayPrice
)
try Task.checkCancellation()
guard isCurrentSession(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
) else {
return
}
let products = try await loadProductsWithRetry(
productIDs: catalog.map(\.productID)
)
try Task.checkCancellation()
guard isCurrentSession(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
) else {
return
}
let productsByID = products.reduce(into: [String: AccountStoreProduct]()) {
$0[$1.id] = $1
}
let loadedOptions: [AccountCreditPurchaseOption] = catalog.compactMap { creditProduct in
guard let product = productsByID[creditProduct.productID] else { return nil }
return AccountCreditPurchaseOption(
productID: creditProduct.productID,
credits: creditProduct.credits,
displayPrice: product.displayPrice
)
}
guard !loadedOptions.isEmpty else {
throw AccountCreditPurchaseError.productUnavailable
}
configuredProductIDs = Set(catalog.map(\.productID))
options = loadedOptions.sorted { $0.credits < $1.credits }
state = .idle
listenForUpdates(accountID: accountID)
_ = await reconcileUnfinished(accountID: accountID)
catalogPhase = .loaded
catalogRefreshErrorKey = nil
listenForUpdates(accountID: accountID, sessionID: sessionID)
_ = await reconcileUnfinished(accountID: accountID, sessionID: sessionID)
} catch is CancellationError {
return
} catch {
state = .failed(messageKey: Self.messageKey(for: error))
guard isCurrentSession(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
) else {
return
}
let messageKey = Self.messageKey(for: error)
catalogRefreshErrorKey = messageKey
catalogPhase = options.isEmpty
? .failed(messageKey: messageKey)
: .loaded
}
}
@discardableResult
func purchase(productID: String, accountID: UUID) async -> Bool {
guard activeAccountID == accountID,
let sessionID,
options.contains(where: { $0.productID == productID }) else {
state = .failed(messageKey: "account.storekit.error.productUnavailable")
return false
}
state = .purchasing(productID: productID)
pendingProductID = nil
cancelSuccessDismissal()
analyticsClient.recordPurchaseStarted()
do {
switch try await store.purchase(productID: productID, accountID: accountID) {
case .success(let verification):
try await process(verification, accountID: accountID)
let result = try await process(
verification,
accountID: accountID,
sessionID: sessionID
)
presentSuccess(credits: result.creditsGranted)
return true
case .pending:
pendingProductID = productID
state = .pending
case .userCancelled:
pendingProductID = nil
state = .idle
analyticsClient.recordPurchaseCancelled()
@unknown default:
pendingProductID = nil
state = .failed(messageKey: "account.storekit.error.unknown")
}
} catch is CancellationError {
return false
} catch {
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
return false
}
pendingProductID = nil
state = .failed(messageKey: Self.messageKey(for: error))
}
return false
@@ -131,6 +252,8 @@ final class AccountCreditPurchaseManager: ObservableObject {
func clearTransientState() {
switch state {
case .succeeded, .failed, .pending:
pendingProductID = nil
cancelSuccessDismissal()
state = .idle
default:
break
@@ -139,6 +262,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
func dismissSuccessMessage() {
guard case .succeeded = state else { return }
cancelSuccessDismissal()
state = .idle
}
@@ -199,10 +323,20 @@ final class AccountCreditPurchaseManager: ObservableObject {
}
func reset() {
prepareTask?.cancel()
prepareTask = nil
prepareRequestID = nil
updatesTask?.cancel()
updatesTask = nil
successDismissTask?.cancel()
successDismissTask = nil
activeAccountID = nil
sessionID = nil
pendingProductID = nil
catalogPhase = .idle
options = []
isRefreshingCatalog = false
catalogRefreshErrorKey = nil
configuredProductIDs = []
lastGrantedBalance = nil
historyPhase = .idle
@@ -213,30 +347,64 @@ final class AccountCreditPurchaseManager: ObservableObject {
}
@discardableResult
private func reconcileUnfinished(accountID: UUID) async -> Bool {
private func reconcileUnfinished(accountID: UUID, sessionID: UUID) async -> Bool {
var reconciled = false
for await verification in store.unfinishedTransactions() {
guard !Task.isCancelled else { break }
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
break
}
do {
try await process(verification, accountID: accountID)
_ = try await process(
verification,
accountID: accountID,
sessionID: sessionID
)
reconciled = true
} catch is CancellationError {
break
} catch {
state = .failed(messageKey: Self.messageKey(for: error))
// Historical recovery is background maintenance; it must not
// replace the current purchase action with stale UI feedback.
continue
}
}
return reconciled
}
private func listenForUpdates(accountID: UUID) {
private func listenForUpdates(accountID: UUID, sessionID: UUID) {
updatesTask?.cancel()
let updates = store.transactionUpdates()
updatesTask = Task { [weak self] in
for await verification in updates {
guard !Task.isCancelled else { return }
guard let self else { return }
guard self.isCurrentSession(
accountID: accountID,
sessionID: sessionID
) else {
return
}
do {
try await self.process(verification, accountID: accountID)
let result = try await self.process(
verification,
accountID: accountID,
sessionID: sessionID
)
if self.pendingProductID == result.productID {
self.pendingProductID = nil
self.presentSuccess(credits: result.creditsGranted)
}
} catch is CancellationError {
return
} catch {
guard self.isCurrentSession(
accountID: accountID,
sessionID: sessionID
), self.pendingProductID != nil else {
continue
}
self.pendingProductID = nil
self.state = .failed(messageKey: Self.messageKey(for: error))
}
}
@@ -245,9 +413,11 @@ final class AccountCreditPurchaseManager: ObservableObject {
private func process(
_ verification: AccountStoreVerification,
accountID: UUID
) async throws {
guard case .verified(let transaction) = verification,
accountID: UUID,
sessionID: UUID
) async throws -> AccountCreditPurchase {
guard isCurrentSession(accountID: accountID, sessionID: sessionID),
case .verified(let transaction) = verification,
transaction.appAccountToken == accountID,
configuredProductIDs.contains(transaction.productID) else {
throw AccountCreditPurchaseError.verificationFailed
@@ -258,23 +428,119 @@ final class AccountCreditPurchaseManager: ObservableObject {
throw AccountCreditPurchaseError.invalidServerResponse
}
await transaction.finish()
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
throw CancellationError()
}
lastGrantedBalance = result.balanceAfter
historyPhase = .idle
nextHistoryCursor = nil
state = .succeeded(credits: result.creditsGranted)
return result
}
private func reset(for accountID: UUID) {
prepareTask?.cancel()
prepareTask = nil
prepareRequestID = nil
updatesTask?.cancel()
updatesTask = nil
successDismissTask?.cancel()
successDismissTask = nil
activeAccountID = accountID
sessionID = UUID()
pendingProductID = nil
catalogPhase = .idle
options = []
isRefreshingCatalog = false
catalogRefreshErrorKey = nil
configuredProductIDs = []
lastGrantedBalance = nil
historyPhase = .idle
isLoadingMoreHistory = false
historyLoadMoreErrorKey = nil
nextHistoryCursor = nil
state = .idle
}
/// Success is acknowledgement of the current purchase action, not durable
/// transaction history. Expire it even when a retained tab never disappears.
private func presentSuccess(credits: Int64) {
cancelSuccessDismissal()
state = .succeeded(credits: credits)
let expectedSessionID = sessionID
let duration = successMessageDuration
successDismissTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: duration)
} catch {
return
}
guard let self,
self.sessionID == expectedSessionID,
self.state == .succeeded(credits: credits) else {
return
}
self.state = .idle
self.successDismissTask = nil
}
}
private func cancelSuccessDismissal() {
successDismissTask?.cancel()
successDismissTask = nil
}
private func finishPrepare(requestID: UUID) {
guard prepareRequestID == requestID else { return }
prepareTask = nil
prepareRequestID = nil
isRefreshingCatalog = false
}
private func isCurrentSession(
accountID: UUID,
sessionID: UUID,
requestID: UUID? = nil
) -> Bool {
guard activeAccountID == accountID, self.sessionID == sessionID else {
return false
}
if let requestID {
return prepareRequestID == requestID
}
return true
}
private func loadProductsWithRetry(
productIDs: [String]
) async throws -> [AccountStoreProduct] {
let expectedIDs = Set(productIDs)
var lastLoaded: [AccountStoreProduct] = []
var lastError: Error?
let retryDelays: [Duration] = [.zero, .milliseconds(200), .milliseconds(600)]
for delay in retryDelays {
if delay != .zero {
try await Task.sleep(for: delay)
}
try Task.checkCancellation()
do {
let loaded = try await store.products(for: productIDs)
lastLoaded = loaded
if Set(loaded.map(\.id)).isSuperset(of: expectedIDs) {
return loaded
}
} catch {
lastError = error
}
}
if !lastLoaded.isEmpty {
return lastLoaded
}
if let lastError {
throw lastError
}
return []
}
private static func messageKey(for error: Error) -> String {
@@ -55,12 +55,25 @@ enum AccountStorePurchaseOutcome: Sendable {
@MainActor
protocol AccountCreditStore {
func product(for productID: String) async throws -> AccountStoreProduct?
func products(for productIDs: [String]) async throws -> [AccountStoreProduct]
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
func allTransactions() -> AsyncStream<AccountStoreVerification>
}
extension AccountCreditStore {
func products(for productIDs: [String]) async throws -> [AccountStoreProduct] {
var loadedProducts: [AccountStoreProduct] = []
for productID in productIDs {
if let product = try await product(for: productID) {
loadedProducts.append(product)
}
}
return loadedProducts
}
}
@MainActor
final class LiveAccountCreditStore: AccountCreditStore {
private var productsByID: [String: Product] = [:]
@@ -70,6 +83,20 @@ final class LiveAccountCreditStore: AccountCreditStore {
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
}
func products(for productIDs: [String]) async throws -> [AccountStoreProduct] {
var seenIDs: Set<String> = []
let orderedIDs = productIDs.filter { seenIDs.insert($0).inserted }
let missingIDs = orderedIDs.filter { productsByID[$0] == nil }
if !missingIDs.isEmpty {
let loadedProducts = try await Product.products(for: missingIDs)
loadedProducts.forEach { productsByID[$0.id] = $0 }
}
return orderedIDs.compactMap { productID in
guard let product = productsByID[productID] else { return nil }
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
}
}
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
guard let product = try await loadProduct(for: productID) else {
return .success(.unverified)
@@ -102,10 +129,8 @@ final class LiveAccountCreditStore: AccountCreditStore {
if let product = productsByID[productID] {
return product
}
let loaded = try await Product.products(for: [productID])
guard let product = loaded.first(where: { $0.id == productID }) else { return nil }
productsByID[productID] = product
return product
_ = try await products(for: [productID])
return productsByID[productID]
}
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
@@ -268,6 +268,14 @@ protocol AccountSessionServicing: Sendable {
func clearManagedGateway() async
}
enum AccountSessionEvent: Sendable {
case expired
}
protocol AccountSessionEventSourcing: Sendable {
func events() async -> AsyncStream<AccountSessionEvent>
}
extension AccountSessionServicing {
func prepareManagedGateway() async throws {}
func clearManagedGateway() async {}
@@ -321,15 +329,18 @@ protocol ReferralProfileServicing: Sendable {
struct AccountDependencies: Sendable {
let sessionService: any AccountSessionServicing
let sessionEventSource: any AccountSessionEventSourcing
let centerService: any AccountCenterServicing
let referralService: any ReferralProfileServicing
init(
sessionService: any AccountSessionServicing,
sessionEventSource: (any AccountSessionEventSourcing)? = nil,
centerService: any AccountCenterServicing,
referralService: (any ReferralProfileServicing)? = nil
) {
self.sessionService = sessionService
self.sessionEventSource = sessionEventSource ?? UnavailableAccountSessionEventSource()
self.centerService = centerService
self.referralService = referralService ?? UnavailableReferralProfileService()
}
@@ -338,6 +349,7 @@ struct AccountDependencies: Sendable {
let service = UnavailableAccountService()
return AccountDependencies(
sessionService: service,
sessionEventSource: UnavailableAccountSessionEventSource(),
centerService: service,
referralService: UnavailableReferralProfileService()
)
@@ -396,6 +408,12 @@ private struct UnavailableReferralProfileService: ReferralProfileServicing {
}
}
private struct UnavailableAccountSessionEventSource: AccountSessionEventSourcing {
func events() async -> AsyncStream<AccountSessionEvent> {
AsyncStream { $0.finish() }
}
}
enum ReferralUniversalLink {
static let host = "osglab.com"
static let codeLength = 22
@@ -50,11 +50,14 @@ final class AccountSessionCoordinator: ObservableObject {
private let pendingReferralStore: any PendingReferralCodeStoring
private let analyticsClient: any AnalyticsClient
private let onAccountAuthenticated: (UUID) async -> Void
private let onAccountSignedOut: () async -> Void
private let onAccountDeleted: () async -> Void
private let accountRefreshInterval: TimeInterval
private let now: () -> Date
private var didAttemptRestore = false
private var accountRefreshTask: Task<Void, Never>?
private var accountRefreshRequestID: UUID?
private var sessionEventsTask: Task<Void, Never>?
init(
dependencies: AccountDependencies,
@@ -67,6 +70,7 @@ final class AccountSessionCoordinator: ObservableObject {
now: @escaping () -> Date = Date.init,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
onAccountSignedOut: @escaping () async -> Void = {},
onAccountDeleted: @escaping () async -> Void = {}
) {
sessionService = dependencies.sessionService
@@ -83,10 +87,24 @@ final class AccountSessionCoordinator: ObservableObject {
self.pendingReferralStore = pendingReferralStore
self.analyticsClient = analyticsClient
self.onAccountAuthenticated = onAccountAuthenticated
self.onAccountSignedOut = onAccountSignedOut
self.onAccountDeleted = onAccountDeleted
self.accountRefreshInterval = accountRefreshInterval
self.now = now
pendingReferralCode = pendingReferralStore.code
let sessionEventSource = dependencies.sessionEventSource
sessionEventsTask = Task { @MainActor [weak self] in
let events = await sessionEventSource.events()
for await event in events {
guard !Task.isCancelled else { return }
await self?.handleSessionEvent(event)
}
}
}
deinit {
accountRefreshTask?.cancel()
sessionEventsTask?.cancel()
}
var isSignedIn: Bool {
@@ -109,15 +127,20 @@ final class AccountSessionCoordinator: ObservableObject {
do {
guard let session = try await sessionService.restoreSession() else {
await sessionService.clearManagedGateway()
await onAccountSignedOut()
sessionPhase = .signedOut
return
}
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
} catch {
await sessionService.clearManagedGateway()
await onAccountSignedOut()
sessionPhase = .signedOut
operationErrorKey = errorMessageKey(
for: error,
@@ -161,6 +184,7 @@ final class AccountSessionCoordinator: ObservableObject {
let session = try await sessionService.signIn(with: payload)
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
@@ -196,13 +220,18 @@ final class AccountSessionCoordinator: ObservableObject {
snapshotPhase = .loading
}
let requestID = UUID()
accountRefreshRequestID = requestID
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.performAccountRefresh(for: accountID)
await self.performAccountRefresh(
for: accountID,
requestID: requestID
)
}
accountRefreshTask = task
await task.value
accountRefreshTask = nil
finishAccountRefresh(requestID: requestID)
}
func updateDisplayName(_ displayName: String) async -> Bool {
@@ -263,7 +292,8 @@ final class AccountSessionCoordinator: ObservableObject {
do {
try await sessionService.signOut()
creditPurchases.reset()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
@@ -286,7 +316,8 @@ final class AccountSessionCoordinator: ObservableObject {
do {
try await sessionService.deleteAccount(with: payload)
await onAccountDeleted()
creditPurchases.reset()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession(removeCache: true)
sessionPhase = .signedOut
@@ -309,12 +340,12 @@ final class AccountSessionCoordinator: ObservableObject {
operationErrorKey = nil
}
private func performAccountRefresh(for accountID: UUID) async {
private func performAccountRefresh(for accountID: UUID, requestID: UUID) async {
isRefreshingAccountData = true
accountRefreshErrorKey = nil
defer { isRefreshingAccountData = false }
do {
try Task.checkCancellation()
let cachedSnapshot: AccountCenterSnapshot?
if case let .loaded(snapshot) = snapshotPhase {
cachedSnapshot = snapshot
@@ -324,15 +355,22 @@ final class AccountSessionCoordinator: ObservableObject {
let snapshot = try await centerService.loadAccountCenter(
cachedSnapshot: cachedSnapshot
)
try Task.checkCancellation()
guard self.accountID == accountID,
accountRefreshRequestID == requestID,
snapshot.account.accountID == accountID else {
return
}
snapshotPhase = .loaded(snapshot)
sessionPhase = .signedIn(snapshot.account)
lastAccountRefreshAt = now()
} catch is CancellationError {
return
} catch {
guard self.accountID == accountID else { return }
guard self.accountID == accountID,
accountRefreshRequestID == requestID else {
return
}
let messageKey = errorMessageKey(
for: error,
fallback: "account.error.load"
@@ -348,11 +386,34 @@ final class AccountSessionCoordinator: ObservableObject {
private func clearAccountRefreshState() {
accountRefreshTask?.cancel()
accountRefreshTask = nil
accountRefreshRequestID = nil
lastAccountRefreshAt = nil
isRefreshingAccountData = false
accountRefreshErrorKey = nil
}
private func finishAccountRefresh(requestID: UUID) {
guard accountRefreshRequestID == requestID else { return }
accountRefreshTask = nil
accountRefreshRequestID = nil
isRefreshingAccountData = false
}
private func handleSessionEvent(_ event: AccountSessionEvent) async {
switch event {
case .expired:
guard isSignedIn else { return }
await sessionService.clearManagedGateway()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
snapshotPhase = .idle
operationErrorKey = "account.error.sessionExpired"
}
}
private func redeemPendingReferralIfNeeded() async {
guard isSignedIn, let code = pendingReferralStore.code else { return }
@@ -2,7 +2,8 @@
// OSGKeyboard · Main App
//
// Adapts the host-private account protocol to the account-center UI. Account
// sessions never enter App Group storage; only scope-limited gateway grants do.
// session credentials never enter App Group storage; only a non-secret local
// eligibility marker and scope-limited gateway grants are shared.
import Foundation
import OSGKeyboardHostSupport
@@ -40,6 +41,7 @@ enum LiveAccountDependencyFactory {
)
return AccountDependencies(
sessionService: service,
sessionEventSource: service,
centerService: service,
referralService: LiveReferralProfileService(apiClient: apiClient)
)
@@ -162,7 +164,10 @@ actor LiveReferralProfileService: ReferralProfileServicing {
}
}
private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
private actor LiveAccountService:
AccountSessionServicing,
AccountSessionEventSourcing,
AccountCenterServicing {
private let apiClient: AccountAPIClient
private let accountCenterLoader: AccountCenterSnapshotLoader
private let integrity: DeviceIntegrityCoordinator
@@ -187,14 +192,37 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
self.configuration = configuration
}
func events() async -> AsyncStream<AccountSessionEvent> {
let invalidations = await apiClient.sessionInvalidations()
return AsyncStream { continuation in
let task = Task {
for await invalidation in invalidations {
switch invalidation {
case .expired:
await self.invalidateManagedGatewaySession()
continuation.yield(.expired)
}
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
func restoreSession() async throws -> AccountSession? {
guard let cachedSession = try await apiClient.currentSession() else { return nil }
guard let cachedSession = try await apiClient.currentSession() else {
await invalidateManagedGatewaySession()
return nil
}
let account: OSGAccount?
do {
account = try await apiClient.account()
} catch let error as AccountAPIError {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
await invalidateManagedGatewaySession()
return nil
default:
account = nil
@@ -203,6 +231,7 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
account = nil
}
let session = try await apiClient.currentSession() ?? cachedSession
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
return uiSession(
session,
@@ -253,7 +282,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
throw error
}
await synchronizeManagedGrant()
let account: OSGAccount
do {
account = try await apiClient.account()
@@ -264,6 +292,8 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
throw error
}
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
OSGDiag.log("signIn completed", category: "account")
return uiSession(
session,
@@ -273,16 +303,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
}
func signOut() async throws {
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
await revokeManagedGrantIfPossible()
do {
try await apiClient.logout()
} catch {
try? await grants.clearGrant()
configuration.setCredentialSource(.byok)
throw error
}
try? await grants.clearGrant()
configuration.setCredentialSource(.byok)
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
@@ -293,13 +323,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
try? await grants.clearGrant()
await integrity.clearLocalKeyState()
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
}
func prepareManagedGateway() async throws {
guard try await apiClient.currentSession() != nil else {
await invalidateManagedGatewaySession()
throw AccountAPIError.sessionUnavailable
}
configuration.setManagedGatewayAccountSessionAvailable(true)
try await ensureManagedGrant()
}
@@ -462,6 +495,14 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
try? await grants.clearGrant()
}
private func invalidateManagedGatewaySession() async {
// Flip the local gate before any await so the extension immediately
// loses access to cached account grants.
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
try? await grants.clearGrant()
}
private func uiSession(
_ session: OSGKeyboardHostSupport.AccountSession,
createdAtEpochSeconds: Int64,
+47
View File
@@ -0,0 +1,47 @@
// AppTab.swift
// OSGKeyboard · Main App
//
// Shared top-level destinations for the native iPhone TabView and iPad sidebar.
import SwiftUI
enum AppTab: Int, CaseIterable {
case keyboard
case skills
case styles
case settings
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
/// Native phone tab symbol: outline when idle, fill when selected.
/// `wand.and.sparkles` has no `.fill` pair.
func dockSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
/// iPad sidebar. Home stays `house` in both states; other tabs follow
/// the same outline / fill pairing as the phone tab bar.
func sidebarSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return "house"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
@@ -1,119 +0,0 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
import OSGKeyboardShared
import SwiftUI
enum AppTab: Int, CaseIterable {
case keyboard
case skills
case styles
case settings
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
/// Phone dock: outline when idle, fill when selected (system tab-bar
/// convention). `wand.and.sparkles` has no `.fill` pair.
func dockSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
/// iPad sidebar. Home stays `house` in both states; other tabs follow
/// the same outline / fill pairing as the phone dock.
func sidebarSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return "house"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
struct MinimalTabBar: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@Namespace private var selectionNamespace
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(Motion.soft) {
selection = tab
}
} label: {
VStack(spacing: 2) {
Image(systemName: tab.dockSystemImage(selected: selection == tab))
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
Text(tab.accessibilityKey)
.font(TypeStyle.caption2)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(tabIconColor(for: tab))
.frame(maxWidth: .infinity)
.frame(height: TabBarDockMetrics.itemHeight)
.background {
if selection == tab {
// Stretch into the dock padding so top/bottom
// leftover matches the side leftover (~5 pt).
Capsule()
.fill(palette.accentMuted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, TabBarDockMetrics.selectionInset)
.padding(
.vertical,
TabBarDockMetrics.selectionInset
- TabBarDockMetrics.dockInsetVertical
)
.matchedGeometryEffect(
id: "main-tab-selection",
in: selectionNamespace
)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.accessibilityKey)
.accessibilityAddTraits(selection == tab ? .isSelected : [])
}
}
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
private func tabIconColor(for tab: AppTab) -> Color {
if selection == tab { return palette.accent }
// 未选中:浅色模式更深、深色模式更亮,提升 dock 可读性。
return colorScheme == .dark
? Color(white: 0.76)
: palette.textSecondary
}
}
@@ -1,91 +1,30 @@
// TabBarVisibility.swift
// OSGKeyboard · Main App
//
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
// Push 进 NavigationStack 子页时隐藏底部系统 tab 栏。
// 子页用 `hidesTabBarWhenPushed()` 声明,根页面保持原生 TabView 导航可见。
import OSGKeyboardShared
import SwiftUI
// MARK: - Preference
enum TabBarHiddenPreferenceKey: PreferenceKey {
static let defaultValue = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = value || nextValue()
}
}
// MARK: - Environment
private enum TabBarVisibleEnvironmentKey: EnvironmentKey {
static let defaultValue = true
}
extension EnvironmentValues {
/// `false` when the custom dock is hidden (detail push / sheet over tab content).
var isTabBarVisible: Bool {
get { self[TabBarVisibleEnvironmentKey.self] }
set { self[TabBarVisibleEnvironmentKey.self] = newValue }
}
}
// MARK: - Modifiers
extension View {
/// Marks this view as a pushed detail screen so `MainTabView` hides the dock.
/// Marks this view as a pushed detail screen so the native tab bar hides.
func hidesTabBarWhenPushed() -> some View {
preference(key: TabBarHiddenPreferenceKey.self, value: true)
toolbar(.hidden, for: .tabBar)
}
/// Bottom inset for scroll *content* above the floating dock (ScrollView inner stacks).
/// Adds ordinary footer breathing room; native TabView owns tab-bar clearance.
func tabBarScrollBottomPadding() -> some View {
modifier(TabBarScrollBottomPaddingModifier())
padding(.bottom, Spacing.lg)
}
/// Bottom scroll-content margin for `List` tab roots. Unlike padding on the list
/// container, this extends the scrollable area so rows can scroll above the dock.
/// Adds a scrollable footer margin to `List` content above the system tab bar.
func tabBarListScrollBottomMargin() -> some View {
modifier(TabBarListScrollBottomMarginModifier())
}
}
enum TabBarDockMetrics {
static let itemHeight: CGFloat = 46
static let iconSize: CGFloat = 22
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~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
)
}
+139 -53
View File
@@ -108,7 +108,11 @@ struct HomeView: View {
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath()
@State private var dictionaryPreviewEntries: [PersonalDictionary.Entry] = []
@State private var dictionarySuggestions: [RimeFrequentTerm] = []
@State private var pendingDictionarySuggestion: RimeFrequentTerm?
private let appGroupStore = AppGroupStore()
private let rimeFrequentTermStore = RimeFrequentTermStore()
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
@@ -189,23 +193,49 @@ struct HomeView: View {
}
.onAppear {
refreshPermissionStatuses()
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
refreshPermissionStatuses()
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshPermissionStatuses()
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onChange(of: path.count) { _, count in
guard count == 0 else { return }
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.alert(
pendingDictionarySuggestion.map {
AppL10n.format("home.card.dictionary.confirm.title", $0.term)
} ?? "",
isPresented: Binding(
get: { pendingDictionarySuggestion != nil },
set: { isPresented in
if !isPresented {
pendingDictionarySuggestion = nil
}
}
),
presenting: pendingDictionarySuggestion
) { suggestion in
Button("home.card.dictionary.confirm.add") {
addSuggestedTerm(suggestion)
}
Button("common.cancel", role: .cancel) {}
} message: { suggestion in
Text(
AppL10n.format(
"home.card.dictionary.confirm.message",
suggestion.commitCount
)
)
}
}
@@ -305,8 +335,8 @@ struct HomeView: View {
/// and the body grows with its rows.
private var homeLibrarySection: some View {
VStack(spacing: Spacing.md) {
historyCard
dictionaryCard
historyCard
}
}
@@ -344,33 +374,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")
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 {
VStack(spacing: 0) {
ForEach(Array(dictionaryPreviewEntries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { libraryRowDivider }
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text(dictionaryDetailLine(for: entry))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
LazyVGrid(
columns: [
GridItem(.adaptive(minimum: 88, maximum: 160), spacing: Spacing.sm)
],
alignment: .leading,
spacing: Spacing.sm
) {
ForEach(dictionarySuggestions) { suggestion in
Button {
pendingDictionarySuggestion = suggestion
} label: {
Text(suggestion.term)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.accent)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.horizontal, Spacing.sm)
.frame(maxWidth: .infinity, minHeight: 38)
.background(
LinearGradient(
colors: [
palette.accent.opacity(0.16),
palette.accent.opacity(0.07)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Capsule()
)
.overlay {
Capsule()
.stroke(palette.accent.opacity(0.22), lineWidth: 0.5)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.sm)
.buttonStyle(.plain)
.accessibilityHint(
AppL10n.format(
"home.card.dictionary.smart.accessibilityHint",
suggestion.commitCount
)
)
}
}
}
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.surfaceCard()
}
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
@@ -424,37 +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
}
return lhs.usageCount > rhs.usageCount
}
.prefix(Self.libraryPreviewLimit)
.map { $0 }
private func refreshDictionarySuggestions() {
let dictionary = appGroupStore.personalDictionary
let suggestions = rimeFrequentTermStore.suggestions(
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
limit: Self.dictionarySuggestionLimit
)
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount
? suggestions
: []
}
private func addSuggestedTerm(_ suggestion: RimeFrequentTerm) {
var dictionary = appGroupStore.personalDictionary
guard let saved = dictionary.upsertManual(term: suggestion.term),
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
return
}
dictionary.entries[index].usageCount = max(
dictionary.entries[index].usageCount,
suggestion.commitCount
)
dictionary.version += 1
appGroupStore.setPersonalDictionary(dictionary)
pendingDictionarySuggestion = nil
refreshDictionarySuggestions()
Task {
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
}
}
private func handlePermissionGuidanceAction() {
@@ -672,6 +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()
+38 -2
View File
@@ -9,6 +9,14 @@ import OSGKeyboardHostSupport
import OSGKeyboardShared
import SwiftUI
enum AnalyticsFirstOpenAttribution {
static let ordinaryLaunch: AnalyticsAcquisitionChannel = .appStoreOrganic
static func trustedChannel(for url: URL) -> AnalyticsAcquisitionChannel? {
ReferralUniversalLink.code(from: url) == nil ? nil : .referral
}
}
struct MainAppRoot: View {
@Environment(\.scenePhase) private var scenePhase
@@ -21,7 +29,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
}
+2 -3
View File
@@ -25,9 +25,8 @@ struct MainSplitView: View {
}
.navigationSplitViewStyle(.balanced)
.background(palette.background)
// No floating dock in split mode — child scroll views should not
// reserve bottom clearance for the phone tab bar.
.environment(\.isTabBarVisible, false)
// No floating tab bar in split mode; shared footer spacing remains
// useful for the final rows in each scroll view.
}
// MARK: - Sidebar
+36 -22
View File
@@ -7,10 +7,8 @@ import SwiftUI
struct MainTabView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var tab: AppTab = .keyboard
@State private var isTabBarHidden = false
private var usesSplitLayout: Bool {
horizontalSizeClass == .regular
@@ -37,29 +35,45 @@ struct MainTabView: View {
// MARK: - Phone layout
private var phoneTabLayout: some View {
ZStack(alignment: .bottom) {
palette.background.ignoresSafeArea()
TabView(selection: $tab) {
Tab(value: AppTab.keyboard) {
HomeView()
} label: {
tabLabel(for: .keyboard)
}
MainTabContent(tab: tab)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.isTabBarVisible, !isTabBarHidden)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !isTabBarHidden {
// Match floating dock + home-indicator clearance so
// page footers / scroll ends sit above MinimalTabBar.
Color.clear.frame(height: TabBarDockMetrics.scrollClearance)
}
}
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
withAnimation(Motion.quick) {
isTabBarHidden = hidden
}
}
Tab(value: AppTab.skills) {
AIAgentSkillsView()
} label: {
tabLabel(for: .skills)
}
if !isTabBarHidden {
MinimalTabBar(selection: $tab)
.transition(.move(edge: .bottom).combined(with: .opacity))
Tab(value: AppTab.styles) {
PolishStylesView()
} label: {
tabLabel(for: .styles)
}
Tab(value: AppTab.settings) {
SettingsView(presentation: .tab)
} label: {
tabLabel(for: .settings)
}
}
.tint(palette.accent)
.tabBarMinimizeBehavior(.never)
// Native TabView owns the floating dock + home-indicator clearance so
// page footers / scroll ends only need their ordinary bottom spacing.
}
private func tabLabel(for item: AppTab) -> some View {
Label {
Text(item.accessibilityKey)
} icon: {
Image(systemName: item.dockSystemImage(selected: tab == item))
}
// TabView applies `.fill` to every SF Symbol by default. Opt out so
// unselected tabs keep their outline while the selected tab uses fill.
.environment(\.symbolVariants, .none)
}
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -11,6 +11,7 @@ import SwiftUI
struct PolishStylesView: View {
@Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var history = SpeechHistoryStore.shared
@State private var catalog = AppGroupStore().polishStyleCatalog
@State private var activeID = AppGroupStore().activePolishStyleId
@@ -19,6 +20,7 @@ struct PolishStylesView: View {
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var errorMessage: String?
@State private var isGeneratingLearnedStyle = false
private let store = AppGroupStore()
private let columns = [
@@ -30,6 +32,7 @@ struct PolishStylesView: View {
NavigationStack {
ScrollView {
CardPageContent(spacing: Spacing.xl) {
styleLearningCard
packGridSection(
title: "polishStyles.builtin.section",
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
@@ -98,6 +101,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")
+95 -27
View File
@@ -46,47 +46,76 @@
"onboarding.experience.privacy.cloud.body" = "Audio or text is sent only when you choose cloud recognition or an AI feature, and only to the configured service.";
"onboarding.experience.privacy.anywhere.title" = "Your AI Agent, in any app";
"onboarding.experience.privacy.anywhere.body" = "Dictate, translate, edit, and act from chat, email, notes, or code.";
"onboarding.experience.permissions.title" = "Let OSGKeyboard hear you";
"onboarding.experience.permissions.title" = "Let us hear you";
"onboarding.experience.permissions.subtitle" = "Voice input needs microphone and speech recognition access. Two system prompts will appear next.";
"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input.";
"onboarding.experience.permissions.later" = "Set Up Later";
"onboarding.experience.keyboard.title" = "Add OSGKeyboard";
"onboarding.experience.keyboard.title" = "Add the keyboard";
"onboarding.experience.keyboard.subtitle" = "Add the keyboard in iOS Settings and turn on Allow Full Access.";
"onboarding.experience.keyboard.ready" = "OSGKeyboard and Full Access are ready";
"onboarding.experience.keyboard.fullAccess" = "Full Access connects the keyboard to the host app microphone and optional AI services";
"onboarding.experience.keyboard.openAgain" = "Open Settings Again";
"onboarding.experience.keyboard.practiceAction" = "Try It Now";
"onboarding.experience.keyboard.previewTitle" = "Keyboards";
"onboarding.experience.practice.skip" = "Try Later";
"onboarding.experience.practice.sample" = "“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";
+95 -27
View File
@@ -46,47 +46,76 @@
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
"onboarding.experience.permissions.title" = "让 OSGKeyboard 听见你";
"onboarding.experience.permissions.title" = "让我们听见你";
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
"onboarding.experience.permissions.later" = "暂时跳过";
"onboarding.experience.keyboard.title" = "添加 OSGKeyboard";
"onboarding.experience.keyboard.title" = "添加键盘";
"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。";
"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪";
"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务";
"onboarding.experience.keyboard.openAgain" = "再次打开设置";
"onboarding.experience.keyboard.practiceAction" = "开始体验";
"onboarding.experience.keyboard.previewTitle" = "键盘";
"onboarding.experience.practice.skip" = "稍后体验";
"onboarding.experience.practice.sample" = "“我明天下午到,到了以后给你消息。”";
"onboarding.experience.keyboardSwitch.title" = "切换键盘";
"onboarding.experience.keyboardSwitch.subtitle" = "激活键盘后,我们会自动验证是否已开启完整访问。";
"onboarding.experience.keyboardSwitch.instruction" = "点击下方输入框,长按地球键,然后选择 OSGKeyboard。";
"onboarding.experience.keyboardSwitch.placeholder" = "在这里切换并验证键盘";
"onboarding.experience.keyboardSwitch.waiting" = "等待 OSGKeyboard 出现…";
"onboarding.experience.keyboardSwitch.ready" = "OSGKeyboard 与完整访问均已验证";
"onboarding.experience.keyboardSwitch.fullAccessMissing" = "已检测到 OSGKeyboard,但尚未检测到完整访问";
"onboarding.experience.keyboardSwitch.timeout" = "尚未检测到 OSGKeyboard,请确认已完成切换";
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完整访问";
"onboarding.experience.keyboardSwitch.waitingAction" = "等待验证";
"onboarding.experience.practice.skip" = "暂时跳过体验";
"onboarding.experience.practice.sample" = "我明天下午到,到了以后给你消息。";
"onboarding.experience.practice.editorTitle" = "新消息";
"onboarding.experience.practice.permissionsTitle" = "还需要语音权限";
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
"onboarding.experience.practice.switchTitle" = "切换到 OSGKeyboard";
"onboarding.experience.practice.switchTitle" = "切换键盘";
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
"onboarding.experience.practice.fullAccessTitle" = "再完成一个设置";
"onboarding.experience.practice.fullAccessTitle" = "开启完全访问";
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。";
"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问";
"onboarding.experience.practice.signInTitle" = "登录,免费体验 AI 成文";
"onboarding.experience.practice.signInSubtitle" = "教学体验默认使用 OSG 积分服务,本次 OOBE 请求不会扣除任何积分。";
"onboarding.experience.practice.signInBody" = "使用 Apple 登录以领取一次免费的 OOBE AI 润色。语音仍在设备端转写。";
"onboarding.experience.practice.cloudTitle" = "启用免费 OOBE 体验";
"onboarding.experience.practice.cloudSubtitle" = "仅将本次转写文字发送到 OSG 托管 AI,并明确标记为 OOBE。";
"onboarding.experience.practice.cloudBody" = "本次教学请求由服务端验证为 OOBE,不预扣、不结算积分。完成后,OSG 积分将作为默认 AI 服务。";
"onboarding.experience.practice.cloudAction" = "同意并免费体验";
"onboarding.experience.practice.creditsTitle" = "正在准备 OSG 积分服务";
"onboarding.experience.practice.creditsSubtitle" = "本次 OOBE 教学完全免费。";
"onboarding.experience.practice.preparingCredits" = "正在准备免费 OOBE 请求…";
"onboarding.experience.practice.creditsFailed" = "暂时无法连接 OSG 积分服务,请重试。不会扣除积分。";
"onboarding.experience.practice.speakTitle" = "现在,说一句话";
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,说完后再点一次。文字会直接出现在这里。";
"onboarding.experience.complete.verifiedTitle" = "就是这样。";
"onboarding.experience.practice.cloudTitle" = "开始体验";
"onboarding.experience.practice.cloudSubtitle" = "四项教学无需账号,也不会消耗积分。";
"onboarding.experience.practice.cloudBody" = "语音在设备端转写;只有本次教学所需的转写文字、示例文本和问题会发送到 OSG 托管 AI。匿名体验凭证将在教学结束后失效。";
"onboarding.experience.practice.cloudAction" = "同意并开始体验";
"onboarding.experience.practice.creditsTitle" = "准备体验";
"onboarding.experience.practice.creditsSubtitle" = "正在验证 App 并获取短时、限权的教学凭证。";
"onboarding.experience.practice.preparingCredits" = "正在安全连接 OSG AI…";
"onboarding.experience.practice.creditsFailed" = "暂时无法准备匿名体验。请检查网络后重试,不会产生任何费用。";
"onboarding.experience.practice.retry" = "重试";
"onboarding.experience.practice.progress.voice" = "1 / 4 · 语音输入";
"onboarding.experience.practice.progress.translate" = "2 / 4 · AI 翻译";
"onboarding.experience.practice.progress.reply" = "3 / 4 · AI 回复";
"onboarding.experience.practice.progress.askAI" = "4 / 4 · 询问 AI";
"onboarding.experience.practice.speakTitle" = "跟着念一句";
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,照着下方文字念,说完后再点一次。";
"onboarding.experience.practice.readAloud" = "请照着念";
"onboarding.experience.practice.voiceSample" = "今天是礼拜四,你打算去哪里玩?";
"onboarding.experience.practice.translateTitle" = "试试翻译";
"onboarding.experience.practice.translateSubtitle" = "先复制下面的示例,再在 OSGKeyboard 中点击「翻译」。确认结果后插入文本框。";
"onboarding.experience.practice.replyTitle" = "试试回复";
"onboarding.experience.practice.replySubtitle" = "使用同一段示例文字,点击键盘中的「回复」,然后插入生成的回复。";
"onboarding.experience.practice.askAITitle" = "问问 AI";
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;松开后等待回答,并将结果插入文本框。";
"onboarding.experience.practice.copyAction" = "复制示例文字";
"onboarding.experience.practice.copied" = "已复制,请切换到 OSGKeyboard";
"onboarding.experience.practice.waiting" = "等待你在键盘中完成这一步…";
"onboarding.experience.login.title" = "登录领 1000 积分";
"onboarding.experience.login.subtitle" = "四项体验已经完成。登录后可继续使用 OSG 托管 AI,无需填写 API Key。";
"onboarding.experience.login.body" = "符合首次注册资格时,1000 积分会自动到账。设备完整性检查用于防止重复领取。";
"onboarding.experience.login.skip" = "暂不登录";
"onboarding.experience.login.signedIn" = "登录成功,积分已刷新";
"onboarding.experience.login.balance %lld" = "当前余额:%lld 积分";
"onboarding.experience.complete.verifiedTitle" = "已经准备好了";
"onboarding.experience.complete.title" = "已经准备好了";
"onboarding.experience.complete.subtitle" = "以后在任何输入框切换到 OSGKeyboard,开口成文,也可以继续交给 AI Agent。";
"onboarding.experience.complete.subtitle" = "点击开始使用";
"onboarding.experience.complete.dictate" = "语音成文";
"onboarding.experience.complete.translate" = "翻译";
"onboarding.experience.complete.agent" = "AI Agent";
"onboarding.experience.complete.edit" = "语音编辑";
"onboarding.experience.complete.reply" = "智能回复";
"onboarding.experience.complete.agent" = "询问 AI";
"onboarding.experience.complete.action" = "开始使用";
"settings.onboarding.replay" = "重新开始权限引导";
@@ -484,6 +513,12 @@
"home.preview.placeholder" = "点这里试试键盘";
"home.card.history.empty" = "还没有语音识别记录";
"home.card.dictionary.empty" = "还没有词条";
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词";
"home.card.dictionary.smart.empty" = "继续使用中文键盘,重复输入的新词会出现在这里。";
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
"home.card.dictionary.confirm.add" = "加入词库";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.mode.cloud" = "云端";
"home.wide.mode.local" = "本机";
@@ -507,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" = "账号已安全保护";