feat(app): add resilient Flow recovery and privacy-safe analytics
Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
This commit is contained in:
@@ -8,9 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。
|
||||||
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
||||||
|
- **Free OOBE polish**: the first-run lesson defaults to on-device transcription plus OSG managed polish, sends an authenticated OOBE purpose tag, and uses a server-enforced one-time complimentary request without reserving or settling credits. / **免费 OOBE 润色**:首次教学默认使用端侧转写与 OSG 托管润色,发送经过身份验证的 OOBE 用途标签,并由服务端执行一次性免费资格,不预扣也不结算积分。
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **AI Agent first run**: replace the technical six-step setup with a concise privacy, permission, and keyboard flow that presents OSGKeyboard as an AI Agent voice keyboard, teaches on the real keyboard, and verifies the first voice insertion before completion. / **AI Agent 首次体验**:以简洁的隐私、权限与键盘流程取代技术化的六步配置,突出 AI Agent 语音输入法定位,并在真实键盘中完成教学、验证首次语音上屏后再结束。
|
||||||
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -115,6 +115,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.osgkeyboard.ios.analytics-sync</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@@ -73,6 +73,45 @@
|
|||||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- App-scoped random installation UUID used for first-party analytics. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeDeviceID</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- Fixed app, keyboard, AI, purchase-page and referral events. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- Coarse feature result, failure category and duration bucket only. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeOtherUsageData</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>NSPrivacyAccessedAPITypes</key>
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||||
<div id="top">
|
<div id="top">
|
||||||
<h1>OSGKeyboard Privacy Policy</h1>
|
<h1>OSGKeyboard Privacy Policy</h1>
|
||||||
<p><strong>Last updated:</strong> August 19, 2026 · <em>v2.0</em></p>
|
<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>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>
|
||||||
|
|
||||||
<h2>What we collect</h2>
|
<h2>What we collect</h2>
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
||||||
<li><strong>Optional OSGKeyboard account and purchases</strong> — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.</li>
|
<li><strong>Optional OSGKeyboard account and purchases</strong> — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.</li>
|
||||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
||||||
|
<li><strong>Optional first-party product analytics</strong> — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, together with an app-scoped random installation identifier and safe app/OS version labels. It never includes keyboard input, audio, transcripts, cursor context, prompts, model output, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued events; turning it back on creates a new installation identifier.</li>
|
||||||
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
||||||
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
|
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
|
||||||
<li><strong>macOS local speech models</strong> — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
<li><strong>macOS local speech models</strong> — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
<h2>What we do not collect</h2>
|
<h2>What we do not collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes.</li>
|
<li>We do <strong>not</strong> log or upload ordinary keystrokes.</li>
|
||||||
<li>We do <strong>not</strong> operate analytics, crash-reporting, or advertising SDKs.</li>
|
<li>We do <strong>not</strong> use third-party analytics, crash-reporting, advertising SDKs, ATT, or IDFA.</li>
|
||||||
<li>We do <strong>not</strong> sell personal data.</li>
|
<li>We do <strong>not</strong> sell personal data.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
||||||
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
<p><strong>Voice history</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>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
<p><strong>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
||||||
|
<p><strong>Product analytics</strong> — queued events expire locally after 34 days and the service accepts events no older than 35 days. Account deletion and analytics opt-out delete the local queue. Analytics identifiers are not used for cross-app tracking or advertising.</p>
|
||||||
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
||||||
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
|
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
|
|
||||||
<hr id="zh">
|
<hr id="zh">
|
||||||
<h1>OSGKeyboard 隐私政策</h1>
|
<h1>OSGKeyboard 隐私政策</h1>
|
||||||
<p><strong>更新日期:</strong>2026 年 8 月 19 日 · <em>v2.0</em></p>
|
<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>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。</p>
|
||||||
|
|
||||||
<h2>我们处理的数据</h2>
|
<h2>我们处理的数据</h2>
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||||
<li><strong>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
|
<li><strong>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
|
||||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
||||||
|
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,并携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识。绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。</li>
|
||||||
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
||||||
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||||
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
|
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
<h2>我们不收集的内容</h2>
|
<h2>我们不收集的内容</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
||||||
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
<li>我们<strong>不会</strong>集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA。</li>
|
||||||
<li>我们<strong>不会</strong>出售个人数据。</li>
|
<li>我们<strong>不会</strong>出售个人数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
||||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||||
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
||||||
|
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期,服务端只接受 35 天以内的事件。注销账号或关闭产品分析会删除本地队列。分析标识不会用于跨 App 跟踪或广告。</p>
|
||||||
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
||||||
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// AnalyticsHostService.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Host-only analytics lifecycle. The shared module owns the wire contract,
|
||||||
|
// SQLite queue and uploader; this type only connects iOS lifecycle signals.
|
||||||
|
|
||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import OSLog
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor AnalyticsUploadSignal: AnalyticsUploadTriggering {
|
||||||
|
typealias Action = @Sendable () async -> Void
|
||||||
|
|
||||||
|
private var action: Action?
|
||||||
|
private var isScheduled = false
|
||||||
|
private let debounce: Duration
|
||||||
|
|
||||||
|
init(debounce: Duration = .milliseconds(250)) {
|
||||||
|
self.debounce = debounce
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(_ action: @escaping Action) {
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestUpload() {
|
||||||
|
guard !isScheduled, let action else { return }
|
||||||
|
isScheduled = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: debounce)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
uploadFinished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await action()
|
||||||
|
uploadFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadFinished() {
|
||||||
|
isScheduled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable {
|
||||||
|
static let shared = HostAnalyticsBearerBridge()
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var provider: (any AnalyticsBearerProviding)?
|
||||||
|
|
||||||
|
func install(_ provider: any AnalyticsBearerProviding) {
|
||||||
|
lock.lock()
|
||||||
|
self.provider = provider
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken() async throws -> String? {
|
||||||
|
try await currentProvider()?.bearerToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshBearerToken(
|
||||||
|
afterUnauthorizedAccessToken failedToken: String?
|
||||||
|
) async throws -> String? {
|
||||||
|
try await currentProvider()?.refreshBearerToken(
|
||||||
|
afterUnauthorizedAccessToken: failedToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentProvider() -> (any AnalyticsBearerProviding)? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
|
||||||
|
let apiClient: AccountAPIClient
|
||||||
|
|
||||||
|
func bearerToken() async throws -> String? {
|
||||||
|
guard try await apiClient.currentSession() != nil else { return nil }
|
||||||
|
return try await apiClient.accessTokenForAuthorizedRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshBearerToken(
|
||||||
|
afterUnauthorizedAccessToken failedToken: String?
|
||||||
|
) async throws -> String? {
|
||||||
|
guard let failedToken, !failedToken.isEmpty else { return nil }
|
||||||
|
return try await apiClient.refreshAccessToken(
|
||||||
|
afterUnauthorizedAccessToken: failedToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HostAnalyticsLogger: AnalyticsLogging {
|
||||||
|
private let logger = Logger(
|
||||||
|
subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios",
|
||||||
|
category: "analytics"
|
||||||
|
)
|
||||||
|
|
||||||
|
func log(_ entry: AnalyticsUploadLogEntry) {
|
||||||
|
let statusCode = entry.statusCode ?? 0
|
||||||
|
let errorCategory = entry.errorCategory?.rawValue ?? "none"
|
||||||
|
logger.info(
|
||||||
|
"outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AnalyticsHostService: ObservableObject {
|
||||||
|
static let shared = AnalyticsHostService()
|
||||||
|
static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync"
|
||||||
|
|
||||||
|
@Published private(set) var isEnabled = true
|
||||||
|
|
||||||
|
let client: any AnalyticsClient
|
||||||
|
|
||||||
|
private let runtime: AnalyticsRuntime
|
||||||
|
private let uploadSignal: AnalyticsUploadSignal
|
||||||
|
private let pathMonitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(
|
||||||
|
label: "com.osgkeyboard.analytics.network",
|
||||||
|
qos: .utility
|
||||||
|
)
|
||||||
|
private var isMonitoringNetwork = false
|
||||||
|
private var foregroundUploadTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(
|
||||||
|
bearerProvider: any AnalyticsBearerProviding = HostAnalyticsBearerBridge.shared,
|
||||||
|
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork()
|
||||||
|
) {
|
||||||
|
let signal = AnalyticsUploadSignal()
|
||||||
|
uploadSignal = signal
|
||||||
|
let runtime = AnalyticsRuntime.mainApp(
|
||||||
|
environment: Self.environment,
|
||||||
|
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
|
||||||
|
network: network,
|
||||||
|
bearerProvider: bearerProvider,
|
||||||
|
trigger: signal,
|
||||||
|
logger: HostAnalyticsLogger()
|
||||||
|
)
|
||||||
|
self.runtime = runtime
|
||||||
|
client = runtime.client
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await signal.install {
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel) {
|
||||||
|
startNetworkMonitoringIfNeeded()
|
||||||
|
Task {
|
||||||
|
await runtime.prepare(
|
||||||
|
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
|
||||||
|
)
|
||||||
|
let enabled = await runtime.isEnabled()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isEnabled = enabled
|
||||||
|
}
|
||||||
|
client.recordSessionActivity()
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appDidBecomeActive() {
|
||||||
|
client.recordSessionActivity()
|
||||||
|
requestImmediateUpload(maximumBatches: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appDidEnterBackground() {
|
||||||
|
Self.scheduleBackgroundRefresh()
|
||||||
|
let identifier = UIApplication.shared.beginBackgroundTask(
|
||||||
|
withName: "analytics-sync"
|
||||||
|
)
|
||||||
|
guard identifier != .invalid else { return }
|
||||||
|
let task = Task {
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 2)
|
||||||
|
await MainActor.run {
|
||||||
|
UIApplication.shared.endBackgroundTask(identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foregroundUploadTask = task
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeAuthenticatedAccount(_ accountID: UUID) async {
|
||||||
|
_ = await runtime.observeAccount(stableIdentifier: accountID.uuidString)
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAccountDeletion() async {
|
||||||
|
await runtime.handleAccountDeletion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
Task {
|
||||||
|
await runtime.setEnabled(enabled)
|
||||||
|
let current = await runtime.isEnabled()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isEnabled = current
|
||||||
|
}
|
||||||
|
if current {
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshEnabledState() {
|
||||||
|
Task {
|
||||||
|
let current = await runtime.isEnabled()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isEnabled = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestImmediateUpload(maximumBatches: Int = 4) {
|
||||||
|
foregroundUploadTask?.cancel()
|
||||||
|
foregroundUploadTask = Task {
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(
|
||||||
|
maximumBatches: maximumBatches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func registerBackgroundTask() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: backgroundTaskIdentifier,
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let refreshTask = task as? BGAppRefreshTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let operation = Task {
|
||||||
|
await AnalyticsHostService.shared.runtime.uploadCoordinator
|
||||||
|
.uploadAvailableEvents(maximumBatches: 4)
|
||||||
|
refreshTask.setTaskCompleted(success: !Task.isCancelled)
|
||||||
|
scheduleBackgroundRefresh()
|
||||||
|
}
|
||||||
|
refreshTask.expirationHandler = {
|
||||||
|
operation.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scheduleBackgroundRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNetworkMonitoringIfNeeded() {
|
||||||
|
guard !isMonitoringNetwork else { return }
|
||||||
|
isMonitoringNetwork = true
|
||||||
|
pathMonitor.pathUpdateHandler = { path in
|
||||||
|
guard path.status == .satisfied else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
AnalyticsHostService.shared.requestImmediateUpload(maximumBatches: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pathMonitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let endpoint = URL(
|
||||||
|
string: "https://account.osglab.com/v1/analytics/events"
|
||||||
|
)!
|
||||||
|
|
||||||
|
private static var environment: AnalyticsEnvironment {
|
||||||
|
let appVersion = Bundle.main.object(
|
||||||
|
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||||
|
) as? String ?? "unknown"
|
||||||
|
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return AnalyticsEnvironment(
|
||||||
|
appVersion: appVersion,
|
||||||
|
osVersion: "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,16 @@ final class AppOpenURLRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class AppURLHandler: NSObject, UIApplicationDelegate {
|
final class AppURLHandler: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [
|
||||||
|
UIApplication.LaunchOptionsKey: Any
|
||||||
|
]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
AnalyticsHostService.registerBackgroundTask()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// SwiftUI `@main` apps get no scene delegate by default. Attach ours so
|
/// SwiftUI `@main` apps get no scene delegate by default. Attach ours so
|
||||||
/// scene-based URL delivery — the only iOS 26 path to `sourceApplication` —
|
/// scene-based URL delivery — the only iOS 26 path to `sourceApplication` —
|
||||||
/// reaches `AppSceneDelegate`. We deliberately do NOT create a window here;
|
/// reaches `AppSceneDelegate`. We deliberately do NOT create a window here;
|
||||||
|
|||||||
@@ -36,14 +36,45 @@ enum FlowPiPStartOutcome: Equatable, Sendable {
|
|||||||
case failed(FlowPiPStartFailure)
|
case failed(FlowPiPStartFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-facing lifecycle published by `FlowSessionManager`.
|
||||||
|
enum FlowPiPLifecycleState: Equatable, Sendable {
|
||||||
|
case inactive
|
||||||
|
case preparing(attempt: Int, total: Int)
|
||||||
|
case recovering(attempt: Int, total: Int)
|
||||||
|
case active
|
||||||
|
case waitingForForeground
|
||||||
|
case failed(FlowPiPStartFailure)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class FlowPictureInPictureController: NSObject {
|
protocol FlowPictureInPictureControlling: AnyObject {
|
||||||
/// User closed the PiP window — host should end the Flow session.
|
var onUnexpectedStop: (() -> Void)? { get set }
|
||||||
var onUserDismissed: (() -> Void)?
|
var isPictureInPictureActive: Bool { get }
|
||||||
|
var generation: UInt64 { get }
|
||||||
|
|
||||||
|
func attachHostView(_ view: UIView)
|
||||||
|
func startAndWait(
|
||||||
|
hostTimeout: TimeInterval,
|
||||||
|
activeTimeout: TimeInterval
|
||||||
|
) async -> FlowPiPStartOutcome
|
||||||
|
func stop()
|
||||||
|
func resetGeneration()
|
||||||
|
func prepareForBackgroundAutoStart() async
|
||||||
|
func reassertKeepAliveAudioSession() async -> Bool
|
||||||
|
func updateWaveformLevels(_ levels: [Float])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FlowPictureInPictureController: NSObject, FlowPictureInPictureControlling {
|
||||||
|
/// PiP disappeared without an in-process teardown. AVKit deliberately does
|
||||||
|
/// not distinguish a user close from a system stop or another app takeover.
|
||||||
|
var onUnexpectedStop: (() -> Void)?
|
||||||
|
|
||||||
private(set) var isPictureInPictureActive = false
|
private(set) var isPictureInPictureActive = false
|
||||||
/// True once a host UIView has been attached (may still be awaiting a window).
|
/// True once a host UIView has been attached (may still be awaiting a window).
|
||||||
private(set) var hasHostView = false
|
private(set) var hasHostView = false
|
||||||
|
/// Monotonic identity for the currently configured AVKit controller.
|
||||||
|
private(set) var generation: UInt64 = 0
|
||||||
|
|
||||||
let displayLayer = AVSampleBufferDisplayLayer()
|
let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
|
|
||||||
@@ -115,29 +146,7 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func start() async -> Bool {
|
func start() async -> Bool {
|
||||||
lastSystemStartFailure = nil
|
guard await prepareControllerForStart() else { return false }
|
||||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
guard hasHostView else { return false }
|
|
||||||
|
|
||||||
// Required before constructing the controller; without an active
|
|
||||||
// session, `isPictureInPicturePossible` stays false forever.
|
|
||||||
guard await activateAudioSessionForPiP() else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a controller was somehow created before audio activation, rebuild.
|
|
||||||
if pipController != nil, !didActivateAudioSessionBeforeController {
|
|
||||||
pipController = nil
|
|
||||||
}
|
|
||||||
configureControllerIfNeeded()
|
|
||||||
if !usesLowPowerVideoCallPiP {
|
|
||||||
warmLogoCacheIfNeeded()
|
|
||||||
animationStartedAt = CACurrentMediaTime()
|
|
||||||
startFramePump()
|
|
||||||
}
|
|
||||||
guard pipController != nil else { return false }
|
|
||||||
|
|
||||||
if pipController?.isPictureInPictureActive == true {
|
if pipController?.isPictureInPictureActive == true {
|
||||||
isPictureInPictureActive = true
|
isPictureInPictureActive = true
|
||||||
@@ -145,6 +154,7 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard pipController?.isPictureInPicturePossible == true else { return false }
|
||||||
if !usesLowPowerVideoCallPiP {
|
if !usesLowPowerVideoCallPiP {
|
||||||
// Prime a few frames before asking the system to start PiP.
|
// Prime a few frames before asking the system to start PiP.
|
||||||
enqueueGuideFrame()
|
enqueueGuideFrame()
|
||||||
@@ -164,17 +174,23 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
) async -> FlowPiPStartOutcome {
|
) async -> FlowPiPStartOutcome {
|
||||||
if isPictureInPictureActive { return .started }
|
if isPictureInPictureActive { return .started }
|
||||||
|
|
||||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
let deadline = Date().addingTimeInterval(activeTimeout)
|
||||||
|
let supportsPiP = await waitForPictureInPictureSupport(
|
||||||
|
timeout: min(max(0, deadline.timeIntervalSinceNow), 0.8)
|
||||||
|
)
|
||||||
|
guard supportsPiP else {
|
||||||
return .failed(.unsupported)
|
return .failed(.unsupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostReady = await waitForHostInWindow(timeout: hostTimeout)
|
let hostReady = await waitForHostInWindow(
|
||||||
|
timeout: min(hostTimeout, max(0, deadline.timeIntervalSinceNow))
|
||||||
|
)
|
||||||
guard hostReady else {
|
guard hostReady else {
|
||||||
return .failed(.hostNotReady)
|
return .failed(.hostNotReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSystemStartFailure = nil
|
lastSystemStartFailure = nil
|
||||||
guard await start() else {
|
guard await prepareControllerForStart() else {
|
||||||
stopFramePump()
|
stopFramePump()
|
||||||
if lastSystemStartFailure != nil {
|
if lastSystemStartFailure != nil {
|
||||||
return .failed(.systemRejected)
|
return .failed(.systemRejected)
|
||||||
@@ -182,17 +198,49 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
return .failed(hasHostView ? .notPossible : .hostNotReady)
|
return .failed(hasHostView ? .notPossible : .hostNotReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
let deadline = Date().addingTimeInterval(activeTimeout)
|
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if isPictureInPictureActive { return .started }
|
if isPictureInPictureActive { return .started }
|
||||||
if pipController?.isPictureInPictureActive == true {
|
if pipController?.isPictureInPictureActive == true {
|
||||||
isPictureInPictureActive = true
|
isPictureInPictureActive = true
|
||||||
return .started
|
return .started
|
||||||
}
|
}
|
||||||
if let pipController, pipController.isPictureInPicturePossible {
|
if pipController?.isPictureInPicturePossible == true {
|
||||||
pipController.startPictureInPicture()
|
break
|
||||||
} else {
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard pipController?.isPictureInPicturePossible == true else {
|
||||||
|
FlowDiagnostics.log(
|
||||||
|
"PiP generation \(generation) never became possible"
|
||||||
|
)
|
||||||
|
return .failed(.notPossible)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !usesLowPowerVideoCallPiP {
|
||||||
|
// Prime a few frames before asking the system to start PiP.
|
||||||
|
enqueueGuideFrame()
|
||||||
|
enqueueGuideFrame()
|
||||||
|
pipController?.invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
// Issue immediately when possible. iOS 27 beta can silently ignore a
|
||||||
|
// request without sending either delegate callback, so retry at a
|
||||||
|
// conservative cadence while keeping this controller generation alive.
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
var lastStartRequestAt = Date()
|
||||||
|
var startRequestCount = 1
|
||||||
|
|
||||||
|
while Date() < deadline {
|
||||||
|
if isPictureInPictureActive { return .started }
|
||||||
|
if pipController?.isPictureInPictureActive == true {
|
||||||
|
isPictureInPictureActive = true
|
||||||
|
return .started
|
||||||
|
}
|
||||||
|
if pipController?.isPictureInPicturePossible == true,
|
||||||
|
Date().timeIntervalSince(lastStartRequestAt) >= 0.25 {
|
||||||
|
pipController?.startPictureInPicture()
|
||||||
|
lastStartRequestAt = Date()
|
||||||
|
startRequestCount += 1
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
}
|
}
|
||||||
@@ -213,16 +261,24 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
failure = .timedOut
|
failure = .timedOut
|
||||||
}
|
}
|
||||||
FlowDiagnostics.log(
|
FlowDiagnostics.log(
|
||||||
"PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true)"
|
"PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true) "
|
||||||
|
+ "requests=\(startRequestCount)"
|
||||||
)
|
)
|
||||||
stop()
|
|
||||||
return .failed(failure)
|
return .failed(failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
resetGeneration()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully releases the AVKit content source so the next attempt cannot
|
||||||
|
/// inherit a controller that the system has already rejected.
|
||||||
|
func resetGeneration() {
|
||||||
isStoppingProgrammatically = true
|
isStoppingProgrammatically = true
|
||||||
stopFramePump()
|
stopFramePump()
|
||||||
pipController?.stopPictureInPicture()
|
let controller = pipController
|
||||||
|
controller?.delegate = nil
|
||||||
|
controller?.stopPictureInPicture()
|
||||||
if !usesLowPowerVideoCallPiP {
|
if !usesLowPowerVideoCallPiP {
|
||||||
displayLayer.sampleBufferRenderer.flush(
|
displayLayer.sampleBufferRenderer.flush(
|
||||||
removingDisplayedImage: true,
|
removingDisplayedImage: true,
|
||||||
@@ -232,6 +288,10 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
isPictureInPictureActive = false
|
isPictureInPictureActive = false
|
||||||
animationStartedAt = nil
|
animationStartedAt = nil
|
||||||
lastSystemStartFailure = nil
|
lastSystemStartFailure = nil
|
||||||
|
pipController = nil
|
||||||
|
videoCallContentController = nil
|
||||||
|
transparentContentView = nil
|
||||||
|
didActivateAudioSessionBeforeController = false
|
||||||
isStoppingProgrammatically = false
|
isStoppingProgrammatically = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +337,32 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
/// Set once we successfully activate audio before building the controller.
|
/// Set once we successfully activate audio before building the controller.
|
||||||
private var didActivateAudioSessionBeforeController = false
|
private var didActivateAudioSessionBeforeController = false
|
||||||
|
|
||||||
|
private func prepareControllerForStart() async -> Bool {
|
||||||
|
lastSystemStartFailure = nil
|
||||||
|
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard hasHostView else { return false }
|
||||||
|
|
||||||
|
// Required before constructing the controller; without an active
|
||||||
|
// session, `isPictureInPicturePossible` stays false forever.
|
||||||
|
guard await activateAudioSessionForPiP() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a controller was somehow created before audio activation, rebuild.
|
||||||
|
if pipController != nil, !didActivateAudioSessionBeforeController {
|
||||||
|
resetGeneration()
|
||||||
|
}
|
||||||
|
configureControllerIfNeeded()
|
||||||
|
if !usesLowPowerVideoCallPiP {
|
||||||
|
warmLogoCacheIfNeeded()
|
||||||
|
animationStartedAt = CACurrentMediaTime()
|
||||||
|
startFramePump()
|
||||||
|
}
|
||||||
|
return pipController != nil
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func activateAudioSessionForPiP() async -> Bool {
|
private func activateAudioSessionForPiP() async -> Bool {
|
||||||
// The first PiP start uses playback. After capture establishes a
|
// The first PiP start uses playback. After capture establishes a
|
||||||
@@ -289,6 +375,24 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func waitForPictureInPictureSupport(timeout: TimeInterval) async -> Bool {
|
||||||
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
return false
|
||||||
|
#else
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func waitForHostInWindow(timeout: TimeInterval) async -> Bool {
|
private func waitForHostInWindow(timeout: TimeInterval) async -> Bool {
|
||||||
if isHostInWindowHierarchy { return true }
|
if isHostInWindowHierarchy { return true }
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
@@ -341,7 +445,9 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
||||||
controller.requiresLinearPlayback = true
|
controller.requiresLinearPlayback = true
|
||||||
pipController = controller
|
pipController = controller
|
||||||
|
generation &+= 1
|
||||||
didActivateAudioSessionBeforeController = true
|
didActivateAudioSessionBeforeController = true
|
||||||
|
FlowDiagnostics.log("PiP controller generation \(generation) configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func releaseAudioSessionForLowPowerPiP() {
|
private func releaseAudioSessionForLowPowerPiP() {
|
||||||
@@ -642,6 +748,7 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
|
|||||||
func pictureInPictureControllerDidStartPictureInPicture(
|
func pictureInPictureControllerDidStartPictureInPicture(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) {
|
) {
|
||||||
|
guard pictureInPictureController === pipController else { return }
|
||||||
isPictureInPictureActive = true
|
isPictureInPictureActive = true
|
||||||
lastSystemStartFailure = nil
|
lastSystemStartFailure = nil
|
||||||
releaseAudioSessionForLowPowerPiP()
|
releaseAudioSessionForLowPowerPiP()
|
||||||
@@ -650,16 +757,18 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
|
|||||||
func pictureInPictureControllerDidStopPictureInPicture(
|
func pictureInPictureControllerDidStopPictureInPicture(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) {
|
) {
|
||||||
|
guard pictureInPictureController === pipController else { return }
|
||||||
isPictureInPictureActive = false
|
isPictureInPictureActive = false
|
||||||
stopFramePump()
|
stopFramePump()
|
||||||
guard !isStoppingProgrammatically else { return }
|
guard !isStoppingProgrammatically else { return }
|
||||||
onUserDismissed?()
|
onUnexpectedStop?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureController(
|
func pictureInPictureController(
|
||||||
_ pictureInPictureController: AVPictureInPictureController,
|
_ pictureInPictureController: AVPictureInPictureController,
|
||||||
failedToStartPictureInPictureWithError error: Error
|
failedToStartPictureInPictureWithError error: Error
|
||||||
) {
|
) {
|
||||||
|
guard pictureInPictureController === pipController else { return }
|
||||||
// Sample-buffer fallback may need warm-up retries. VideoCall PiP uses
|
// Sample-buffer fallback may need warm-up retries. VideoCall PiP uses
|
||||||
// the automatic-inline path plus the bounded startAndWait fallback.
|
// the automatic-inline path plus the bounded startAndWait fallback.
|
||||||
lastSystemStartFailure = error
|
lastSystemStartFailure = error
|
||||||
|
|||||||
@@ -31,6 +31,67 @@ enum FlowUtteranceLifecyclePolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FlowPiPActivationTrigger: String, Sendable {
|
||||||
|
case coldStart
|
||||||
|
case foreground
|
||||||
|
case existingSession
|
||||||
|
case startupRecovery
|
||||||
|
case unexpectedStop
|
||||||
|
case healthCheck
|
||||||
|
case manualRetry
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlowPiPReconciliationDecision: Equatable, Sendable {
|
||||||
|
case noSessionIntent
|
||||||
|
case alreadyActive
|
||||||
|
case waitForForeground
|
||||||
|
case startRecovery
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlowPiPRecoveryPolicy {
|
||||||
|
static let maxAttempts = 3
|
||||||
|
static let totalBudget: TimeInterval = 5
|
||||||
|
|
||||||
|
static func reconciliationDecision(
|
||||||
|
wantsActiveSession: Bool,
|
||||||
|
isAppForeground: Bool,
|
||||||
|
isPictureInPictureActive: Bool
|
||||||
|
) -> FlowPiPReconciliationDecision {
|
||||||
|
guard wantsActiveSession else { return .noSessionIntent }
|
||||||
|
if isPictureInPictureActive { return .alreadyActive }
|
||||||
|
return isAppForeground ? .startRecovery : .waitForForeground
|
||||||
|
}
|
||||||
|
|
||||||
|
static func canContinue(
|
||||||
|
operation: UInt64,
|
||||||
|
currentOperation: UInt64,
|
||||||
|
wantsActiveSession: Bool,
|
||||||
|
isAppForeground: Bool,
|
||||||
|
taskIsCancelled: Bool
|
||||||
|
) -> Bool {
|
||||||
|
operation == currentOperation
|
||||||
|
&& wantsActiveSession
|
||||||
|
&& isAppForeground
|
||||||
|
&& !taskIsCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
static func retryDelay(beforeAttempt attempt: Int) -> TimeInterval {
|
||||||
|
switch attempt {
|
||||||
|
case 1: return 0
|
||||||
|
case 2: return 0.25
|
||||||
|
default: return 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hostTimeout(attempt: Int, remainingBudget: TimeInterval) -> TimeInterval {
|
||||||
|
min(attempt == 1 ? 1.5 : 0.25, max(0, remainingBudget))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func activeTimeout(remainingBudget: TimeInterval) -> TimeInterval {
|
||||||
|
min(1.4, max(0, remainingBudget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class FlowSessionManager: ObservableObject {
|
final class FlowSessionManager: ObservableObject {
|
||||||
private struct PrefilledAIRequest {
|
private struct PrefilledAIRequest {
|
||||||
@@ -45,12 +106,14 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
@Published private(set) var sessionExpiresAt: Date?
|
@Published private(set) var sessionExpiresAt: Date?
|
||||||
/// Non-nil when continuous capture failed or permissions are missing.
|
/// Non-nil when continuous capture failed or permissions are missing.
|
||||||
@Published private(set) var sessionWarning: String?
|
@Published private(set) var sessionWarning: String?
|
||||||
|
@Published private(set) var pipLifecycleState: FlowPiPLifecycleState = .inactive
|
||||||
|
|
||||||
private let capture = FlowContinuousCapture()
|
private let capture = FlowContinuousCapture()
|
||||||
private let pipController = FlowPictureInPictureController()
|
private let pipController: any FlowPictureInPictureControlling
|
||||||
private let store = AppGroupStore()
|
private let store = AppGroupStore()
|
||||||
|
private let analyticsClient: any AnalyticsClient
|
||||||
/// Cloud / local polish via the user-configured LLM provider.
|
/// Cloud / local polish via the user-configured LLM provider.
|
||||||
private let polisher = PolishingService()
|
private let polisher: PolishingService
|
||||||
/// AI-mode turns are intentionally process-local and never persisted.
|
/// AI-mode turns are intentionally process-local and never persisted.
|
||||||
private let aiConversations = AIConversationStore()
|
private let aiConversations = AIConversationStore()
|
||||||
/// Cached ASR instance. The only on-device backend is iOS
|
/// Cached ASR instance. The only on-device backend is iOS
|
||||||
@@ -98,6 +161,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private var pendingSourceHistoryEntryID: UUID?
|
private var pendingSourceHistoryEntryID: UUID?
|
||||||
private var pendingSourceHistoryEntryRevision: Int64?
|
private var pendingSourceHistoryEntryRevision: Int64?
|
||||||
private var pendingAIConversationID: UUID?
|
private var pendingAIConversationID: UUID?
|
||||||
|
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||||
private var pendingProcessingDeadlineAt: TimeInterval?
|
private var pendingProcessingDeadlineAt: TimeInterval?
|
||||||
private var pendingStopUtteranceId: UUID?
|
private var pendingStopUtteranceId: UUID?
|
||||||
private var currentCommandSeq: Int64 = 0
|
private var currentCommandSeq: Int64 = 0
|
||||||
@@ -110,6 +174,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
@Published private(set) var isUtteranceProcessing = false
|
@Published private(set) var isUtteranceProcessing = false
|
||||||
private var finalizeTask: Task<Void, Never>?
|
private var finalizeTask: Task<Void, Never>?
|
||||||
private var asrTask: Task<Void, Never>?
|
private var asrTask: Task<Void, Never>?
|
||||||
|
private var transcriptionAnalyticsOperation: (any AnalyticsAIOperation)?
|
||||||
private var utteranceSafetyTask: Task<Void, Never>?
|
private var utteranceSafetyTask: Task<Void, Never>?
|
||||||
private var chunkedPipeline: ChunkedUtterancePipeline?
|
private var chunkedPipeline: ChunkedUtterancePipeline?
|
||||||
private var currentPartial = ""
|
private var currentPartial = ""
|
||||||
@@ -131,7 +196,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private var isAppForeground = false
|
private var isAppForeground = false
|
||||||
/// True while handling a keyboard-initiated `startflow` cold start.
|
/// True while handling a keyboard-initiated `startflow` cold start.
|
||||||
private var isColdStartHandoff = false
|
private var isColdStartHandoff = false
|
||||||
private var coldStartRecoveryTask: Task<Void, Never>?
|
/// User intent outlives a transient PiP loss; only explicit teardown clears it.
|
||||||
|
private var wantsActiveSession = false
|
||||||
|
/// Invalidates late async completions after retry, stop, or process teardown.
|
||||||
|
private var pipRecoveryOperation: UInt64 = 0
|
||||||
var shouldDeferHostHeavyWork: Bool {
|
var shouldDeferHostHeavyWork: Bool {
|
||||||
isStarting
|
isStarting
|
||||||
|| startingUtteranceId != nil
|
|| startingUtteranceId != nil
|
||||||
@@ -148,7 +216,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
/// recreate the `@StateObject`-owned manager within the same process).
|
/// recreate the `@StateObject`-owned manager within the same process).
|
||||||
private static var didRunLaunchReconciliation = false
|
private static var didRunLaunchReconciliation = false
|
||||||
|
|
||||||
init() {
|
init(
|
||||||
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
|
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController()
|
||||||
|
) {
|
||||||
|
self.analyticsClient = analyticsClient
|
||||||
|
self.polisher = PolishingService(analyticsClient: analyticsClient)
|
||||||
|
self.pipController = pipController
|
||||||
// Sessions are (re)started explicitly on app foreground via
|
// Sessions are (re)started explicitly on app foreground via
|
||||||
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
||||||
// stored session here — after a force-quit that would resurrect capture
|
// stored session here — after a force-quit that would resurrect capture
|
||||||
@@ -194,10 +268,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
kind: .recognitionInterrupted
|
kind: .recognitionInterrupted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
pipController.onUserDismissed = { [weak self] in
|
pipController.onUnexpectedStop = { [weak self] in
|
||||||
guard let self, self.isActive else { return }
|
guard let self, self.wantsActiveSession else { return }
|
||||||
self.debug("PiP dismissed by user — ending Flow session")
|
self.handleUnexpectedPiPStop()
|
||||||
self.endSession()
|
|
||||||
}
|
}
|
||||||
FlowTerminationCoordinator.register(self)
|
FlowTerminationCoordinator.register(self)
|
||||||
}
|
}
|
||||||
@@ -257,25 +330,25 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isActive {
|
if isActive {
|
||||||
|
wantsActiveSession = true
|
||||||
extendSession(duration: duration)
|
extendSession(duration: duration)
|
||||||
|
if pipController.isPictureInPictureActive {
|
||||||
|
pipLifecycleState = .active
|
||||||
refreshHostReady()
|
refreshHostReady()
|
||||||
|
} else {
|
||||||
|
ensurePiPActive(
|
||||||
|
trigger: coldStart ? .coldStart : .existingSession,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !isStarting else {
|
wantsActiveSession = true
|
||||||
traceState("startSession.ignored", extra: "reason=alreadyStarting")
|
ensurePiPActive(
|
||||||
return
|
trigger: coldStart ? .coldStart : .foreground,
|
||||||
}
|
duration: duration
|
||||||
// Claim the flag synchronously: on a cold start the URL router and
|
)
|
||||||
// `activateOnForeground()` both fire in the same runloop turn, and
|
|
||||||
// setting it inside the async body let two start bodies interleave.
|
|
||||||
isStarting = true
|
|
||||||
|
|
||||||
startTask?.cancel()
|
|
||||||
startTask = Task { @MainActor [weak self] in
|
|
||||||
await self?.startSessionAsync(duration: duration)
|
|
||||||
self?.handleColdStartAfterSessionReady()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears App Group Flow state left behind when the host process was killed
|
/// Clears App Group Flow state left behind when the host process was killed
|
||||||
@@ -339,9 +412,19 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-requested recovery always discards the rejected AVKit generation.
|
||||||
|
func retryPiPRecovery() {
|
||||||
|
guard AppGroup.isAvailable else { return }
|
||||||
|
guard AppPermissions.flowRequirementsMet else {
|
||||||
|
sessionWarning = permissionWarningMessage()
|
||||||
|
FlowSessionBridge.setHostReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wantsActiveSession = true
|
||||||
|
ensurePiPActive(trigger: .manualRetry, duration: nil, forceReset: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func completeColdStartHandoff() {
|
private func completeColdStartHandoff() {
|
||||||
coldStartRecoveryTask?.cancel()
|
|
||||||
coldStartRecoveryTask = nil
|
|
||||||
isColdStartHandoff = false
|
isColdStartHandoff = false
|
||||||
if isActive {
|
if isActive {
|
||||||
refreshHostReady()
|
refreshHostReady()
|
||||||
@@ -360,9 +443,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wantsActiveSession = false
|
||||||
|
pipRecoveryOperation &+= 1
|
||||||
|
pipLifecycleState = .inactive
|
||||||
isColdStartHandoff = false
|
isColdStartHandoff = false
|
||||||
coldStartRecoveryTask?.cancel()
|
|
||||||
coldStartRecoveryTask = nil
|
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = nil
|
startTask = nil
|
||||||
startupAudioHealthTask?.cancel()
|
startupAudioHealthTask?.cancel()
|
||||||
@@ -433,7 +517,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func endSession() {
|
func endSession() {
|
||||||
guard isActive else { return }
|
guard isActive || isStarting || wantsActiveSession else { return }
|
||||||
debug("Flow session ended")
|
debug("Flow session ended")
|
||||||
if isUtteranceRecording || isUtteranceProcessing,
|
if isUtteranceRecording || isUtteranceProcessing,
|
||||||
claimTerminal(utteranceId: currentUtteranceId) {
|
claimTerminal(utteranceId: currentUtteranceId) {
|
||||||
@@ -444,9 +528,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wantsActiveSession = false
|
||||||
|
pipRecoveryOperation &+= 1
|
||||||
|
pipLifecycleState = .inactive
|
||||||
isColdStartHandoff = false
|
isColdStartHandoff = false
|
||||||
coldStartRecoveryTask?.cancel()
|
|
||||||
coldStartRecoveryTask = nil
|
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = nil
|
startTask = nil
|
||||||
startupAudioHealthTask?.cancel()
|
startupAudioHealthTask?.cancel()
|
||||||
@@ -493,6 +578,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
FlowSessionBridge.markSessionInactive()
|
FlowSessionBridge.markSessionInactive()
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
isActive = false
|
isActive = false
|
||||||
|
isStarting = false
|
||||||
sessionExpiresAt = nil
|
sessionExpiresAt = nil
|
||||||
sessionWarning = nil
|
sessionWarning = nil
|
||||||
currentPartial = ""
|
currentPartial = ""
|
||||||
@@ -516,19 +602,27 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
case .active:
|
case .active:
|
||||||
setAppForeground(true)
|
setAppForeground(true)
|
||||||
resumeAfterForeground()
|
resumeAfterForeground()
|
||||||
|
if wantsActiveSession, !pipController.isPictureInPictureActive {
|
||||||
|
ensurePiPActive(trigger: .foreground, duration: nil)
|
||||||
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
|
setAppForeground(false)
|
||||||
writeHeartbeatIfActive()
|
writeHeartbeatIfActive()
|
||||||
if isActive {
|
if isActive, pipController.isPictureInPictureActive {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
await self?.pipController.prepareForBackgroundAutoStart()
|
await self?.pipController.prepareForBackgroundAutoStart()
|
||||||
}
|
}
|
||||||
|
} else if wantsActiveSession {
|
||||||
|
pausePiPRecoveryUntilForeground()
|
||||||
}
|
}
|
||||||
case .background:
|
case .background:
|
||||||
setAppForeground(false)
|
setAppForeground(false)
|
||||||
if isActive {
|
if isActive, pipController.isPictureInPictureActive {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
await self?.pipController.prepareForBackgroundAutoStart()
|
await self?.pipController.prepareForBackgroundAutoStart()
|
||||||
}
|
}
|
||||||
|
} else if wantsActiveSession {
|
||||||
|
pausePiPRecoveryUntilForeground()
|
||||||
}
|
}
|
||||||
if isColdStartHandoff {
|
if isColdStartHandoff {
|
||||||
completeColdStartHandoff()
|
completeColdStartHandoff()
|
||||||
@@ -549,6 +643,33 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
FlowSessionBridge.writeHeartbeat()
|
FlowSessionBridge.writeHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleUnexpectedPiPStop() {
|
||||||
|
guard wantsActiveSession else { return }
|
||||||
|
traceState("pip.unexpectedStop")
|
||||||
|
pipLifecycleState = .waitingForForeground
|
||||||
|
sessionWarning = nil
|
||||||
|
if isUtteranceRecording {
|
||||||
|
failUtterance(
|
||||||
|
message: AppL10n.string("flow.error.recognitionInterrupted"),
|
||||||
|
kind: .recognitionInterrupted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FlowSessionBridge.setHostReady(false)
|
||||||
|
refreshHostReady()
|
||||||
|
// Do not immediately fight another app for the system-owned PiP slot.
|
||||||
|
// The scene-active path or the existing heartbeat performs reconciliation.
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pausePiPRecoveryUntilForeground() {
|
||||||
|
startTask?.cancel()
|
||||||
|
startTask = nil
|
||||||
|
pipRecoveryOperation &+= 1
|
||||||
|
pipController.resetGeneration()
|
||||||
|
isStarting = false
|
||||||
|
pipLifecycleState = .waitingForForeground
|
||||||
|
refreshHostReady()
|
||||||
|
}
|
||||||
|
|
||||||
private func resumeAfterForeground() {
|
private func resumeAfterForeground() {
|
||||||
guard isActive else {
|
guard isActive else {
|
||||||
return
|
return
|
||||||
@@ -723,36 +844,192 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Session start
|
// MARK: - Session start
|
||||||
|
|
||||||
private func startSessionAsync(duration: TimeInterval?) async {
|
private func ensurePiPActive(
|
||||||
// `isStarting` was claimed synchronously in `startSession()`.
|
trigger: FlowPiPActivationTrigger,
|
||||||
defer { isStarting = false }
|
duration: TimeInterval?,
|
||||||
|
forceReset: Bool = false
|
||||||
|
) {
|
||||||
|
switch FlowPiPRecoveryPolicy.reconciliationDecision(
|
||||||
|
wantsActiveSession: wantsActiveSession,
|
||||||
|
isAppForeground: isAppForeground,
|
||||||
|
isPictureInPictureActive: pipController.isPictureInPictureActive
|
||||||
|
) {
|
||||||
|
case .noSessionIntent:
|
||||||
|
return
|
||||||
|
case .alreadyActive:
|
||||||
|
pipLifecycleState = .active
|
||||||
|
sessionWarning = nil
|
||||||
|
if isActive {
|
||||||
|
refreshHostReady()
|
||||||
|
} else {
|
||||||
|
activateFlowSessionAfterPiPProof(duration: duration)
|
||||||
|
}
|
||||||
|
handleColdStartAfterSessionReady()
|
||||||
|
return
|
||||||
|
case .waitForForeground:
|
||||||
|
pipLifecycleState = .waitingForForeground
|
||||||
|
FlowSessionBridge.setHostReady(false)
|
||||||
|
refreshHostReady()
|
||||||
|
return
|
||||||
|
case .startRecovery:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard startTask == nil else {
|
||||||
|
traceState(
|
||||||
|
"pipRecovery.coalesced",
|
||||||
|
extra: "trigger=\(trigger.rawValue)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the flag synchronously: on a cold start the URL router and
|
||||||
|
// `activateOnForeground()` both fire in the same runloop turn, and
|
||||||
|
// setting it inside the async body let two start bodies interleave.
|
||||||
|
isStarting = true
|
||||||
|
pipRecoveryOperation &+= 1
|
||||||
|
let operation = pipRecoveryOperation
|
||||||
|
let recoveringExistingSession = isActive
|
||||||
|
let resetFirstGeneration = forceReset
|
||||||
|
|| recoveringExistingSession
|
||||||
|
|| pipController.generation > 0
|
||||||
|
pipLifecycleState = recoveringExistingSession
|
||||||
|
? .recovering(attempt: 1, total: FlowPiPRecoveryPolicy.maxAttempts)
|
||||||
|
: .preparing(attempt: 1, total: FlowPiPRecoveryPolicy.maxAttempts)
|
||||||
|
|
||||||
|
startTask = Task { @MainActor [weak self] in
|
||||||
|
await self?.startSessionAsync(
|
||||||
|
duration: duration,
|
||||||
|
trigger: trigger,
|
||||||
|
operation: operation,
|
||||||
|
resetFirstGeneration: resetFirstGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSessionAsync(
|
||||||
|
duration: TimeInterval?,
|
||||||
|
trigger: FlowPiPActivationTrigger,
|
||||||
|
operation: UInt64,
|
||||||
|
resetFirstGeneration: Bool
|
||||||
|
) async {
|
||||||
|
// `isStarting` was claimed synchronously in `ensurePiPActive()`.
|
||||||
|
defer {
|
||||||
|
if operation == pipRecoveryOperation {
|
||||||
|
isStarting = false
|
||||||
|
startTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
traceState("startSessionAsync.begin")
|
traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)")
|
||||||
sessionWarning = nil
|
sessionWarning = nil
|
||||||
|
|
||||||
guard AppPermissions.flowRequirementsMet else {
|
guard AppPermissions.flowRequirementsMet else {
|
||||||
sessionWarning = permissionWarningMessage()
|
sessionWarning = permissionWarningMessage()
|
||||||
|
pipLifecycleState = .failed(.notPossible)
|
||||||
traceState("startSessionAsync.blocked", extra: "reason=permissions")
|
traceState("startSessionAsync.blocked", extra: "reason=permissions")
|
||||||
FlowSessionBridge.setHostReady(false)
|
FlowSessionBridge.setHostReady(false)
|
||||||
isColdStartHandoff = false
|
isColdStartHandoff = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch await pipController.startAndWait() {
|
let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
|
||||||
case .started:
|
var lastFailure: FlowPiPStartFailure = .timedOut
|
||||||
activateFlowSessionAfterPiPProof(duration: duration)
|
|
||||||
traceState("startSessionAsync.ready")
|
for attempt in 1...FlowPiPRecoveryPolicy.maxAttempts {
|
||||||
debug("Flow session started (PiP keep-alive), mic released between utterances")
|
guard FlowPiPRecoveryPolicy.canContinue(
|
||||||
case .failed(let failure):
|
operation: operation,
|
||||||
let message = AppL10n.string(failure.localizationKey)
|
currentOperation: pipRecoveryOperation,
|
||||||
sessionWarning = message
|
wantsActiveSession: wantsActiveSession,
|
||||||
traceState("startSessionAsync.failed", extra: "reason=pipUnavailable failure=\(failure)")
|
isAppForeground: isAppForeground,
|
||||||
|
taskIsCancelled: Task.isCancelled
|
||||||
|
) else {
|
||||||
|
if operation == pipRecoveryOperation,
|
||||||
|
wantsActiveSession,
|
||||||
|
!Task.isCancelled,
|
||||||
|
!isAppForeground {
|
||||||
|
pipLifecycleState = .waitingForForeground
|
||||||
FlowSessionBridge.setHostReady(false)
|
FlowSessionBridge.setHostReady(false)
|
||||||
if isColdStartHandoff {
|
|
||||||
scheduleColdStartRecovery(duration: duration)
|
|
||||||
}
|
}
|
||||||
debug("PiP keep-alive failed to start: \(failure)")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let delay = FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: attempt)
|
||||||
|
if delay > 0 {
|
||||||
|
try? await Task.sleep(
|
||||||
|
nanoseconds: UInt64(delay * 1_000_000_000)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: operation,
|
||||||
|
currentOperation: pipRecoveryOperation,
|
||||||
|
wantsActiveSession: wantsActiveSession,
|
||||||
|
isAppForeground: isAppForeground,
|
||||||
|
taskIsCancelled: Task.isCancelled
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
if resetFirstGeneration || attempt > 1 {
|
||||||
|
pipController.resetGeneration()
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = deadline.timeIntervalSinceNow
|
||||||
|
guard remaining > 0 else { break }
|
||||||
|
pipLifecycleState = isActive
|
||||||
|
? .recovering(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
|
||||||
|
: .preparing(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
|
||||||
|
traceState(
|
||||||
|
"pipRecovery.attempt",
|
||||||
|
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) generation=\(pipController.generation)"
|
||||||
|
)
|
||||||
|
|
||||||
|
let outcome = await pipController.startAndWait(
|
||||||
|
hostTimeout: FlowPiPRecoveryPolicy.hostTimeout(
|
||||||
|
attempt: attempt,
|
||||||
|
remainingBudget: remaining
|
||||||
|
),
|
||||||
|
activeTimeout: FlowPiPRecoveryPolicy.activeTimeout(
|
||||||
|
remainingBudget: deadline.timeIntervalSinceNow
|
||||||
|
)
|
||||||
|
)
|
||||||
|
guard FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: operation,
|
||||||
|
currentOperation: pipRecoveryOperation,
|
||||||
|
wantsActiveSession: wantsActiveSession,
|
||||||
|
isAppForeground: isAppForeground,
|
||||||
|
taskIsCancelled: Task.isCancelled
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case .started:
|
||||||
|
sessionWarning = nil
|
||||||
|
pipLifecycleState = .active
|
||||||
|
if isActive {
|
||||||
|
refreshHostReady()
|
||||||
|
} else {
|
||||||
|
activateFlowSessionAfterPiPProof(duration: duration)
|
||||||
|
}
|
||||||
|
traceState(
|
||||||
|
"startSessionAsync.ready",
|
||||||
|
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) generation=\(pipController.generation)"
|
||||||
|
)
|
||||||
|
handleColdStartAfterSessionReady()
|
||||||
|
debug("Flow session started (PiP keep-alive), mic released between utterances")
|
||||||
|
return
|
||||||
|
case .failed(let failure):
|
||||||
|
lastFailure = failure
|
||||||
|
traceState(
|
||||||
|
"startSessionAsync.failed",
|
||||||
|
extra: "reason=pipUnavailable trigger=\(trigger.rawValue) attempt=\(attempt) failure=\(failure)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pipController.resetGeneration()
|
||||||
|
pipLifecycleState = .failed(lastFailure)
|
||||||
|
sessionWarning = AppL10n.string(lastFailure.localizationKey)
|
||||||
|
FlowSessionBridge.setHostReady(false)
|
||||||
|
refreshHostReady()
|
||||||
|
debug("PiP keep-alive failed after bounded recovery: \(lastFailure)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) {
|
private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) {
|
||||||
@@ -762,6 +1039,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId)
|
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId)
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
isActive = true
|
isActive = true
|
||||||
|
wantsActiveSession = true
|
||||||
|
pipLifecycleState = .active
|
||||||
// Low-profile PiP is a system-owned keep-alive surface. Keeping the
|
// Low-profile PiP is a system-owned keep-alive surface. Keeping the
|
||||||
// display awake wastes far more power than the static PiP itself.
|
// display awake wastes far more power than the static PiP itself.
|
||||||
ScreenWakeLock.release()
|
ScreenWakeLock.release()
|
||||||
@@ -862,18 +1141,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
guard isColdStartHandoff, isActive else { return }
|
guard isColdStartHandoff, isActive else { return }
|
||||||
sessionWarning = nil
|
sessionWarning = nil
|
||||||
if !pipController.isPictureInPictureActive {
|
if !pipController.isPictureInPictureActive {
|
||||||
switch await pipController.startAndWait() {
|
ensurePiPActive(trigger: .existingSession, duration: nil)
|
||||||
case .started:
|
|
||||||
break
|
|
||||||
case .failed(let failure):
|
|
||||||
let message = AppL10n.string(failure.localizationKey)
|
|
||||||
sessionWarning = message
|
|
||||||
FlowSessionBridge.setHostReady(false)
|
|
||||||
scheduleColdStartRecovery(duration: nil)
|
|
||||||
debug("existing PiP session failed cold-start restart: \(failure)")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
pipLifecycleState = .active
|
||||||
refreshHostReady()
|
refreshHostReady()
|
||||||
handleColdStartAfterSessionReady()
|
handleColdStartAfterSessionReady()
|
||||||
}
|
}
|
||||||
@@ -895,7 +1166,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let message = AppL10n.string("flow.session.error.notPossible")
|
let message = AppL10n.string("flow.session.error.notPossible")
|
||||||
sessionWarning = message
|
sessionWarning = message
|
||||||
FlowSessionBridge.setHostReady(false)
|
FlowSessionBridge.setHostReady(false)
|
||||||
|
if !pipController.isPictureInPictureActive {
|
||||||
scheduleColdStartRecovery(duration: nil)
|
scheduleColdStartRecovery(duration: nil)
|
||||||
|
}
|
||||||
debug("cold-start blocked: host ready contract not published")
|
debug("cold-start blocked: host ready contract not published")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -927,31 +1200,14 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
/// bounce the audio session and rebuild. Force-quit relaunches routinely
|
/// bounce the audio session and rebuild. Force-quit relaunches routinely
|
||||||
/// inherit stale mediaserverd state that only a rebuild clears.
|
/// inherit stale mediaserverd state that only a rebuild clears.
|
||||||
private func scheduleColdStartRecovery(duration: TimeInterval?) {
|
private func scheduleColdStartRecovery(duration: TimeInterval?) {
|
||||||
coldStartRecoveryTask?.cancel()
|
|
||||||
coldStartRecoveryTask = Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
// The failed controller has been fully torn down. Give the app
|
// The failed controller has been fully torn down. Give the app
|
||||||
// scene and Pegasus service a short settling interval before the
|
// scene and Pegasus service a short settling interval before the
|
||||||
// one bounded cold-start retry creates a new controller generation.
|
// one bounded cold-start retry creates a new controller generation.
|
||||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
ensurePiPActive(
|
||||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
trigger: .startupRecovery,
|
||||||
let outcome = await self.pipController.startAndWait()
|
duration: duration,
|
||||||
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
|
forceReset: true
|
||||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
)
|
||||||
switch outcome {
|
|
||||||
case .started:
|
|
||||||
if self.isActive {
|
|
||||||
self.sessionWarning = nil
|
|
||||||
self.refreshHostReady()
|
|
||||||
self.handleColdStartAfterSessionReady()
|
|
||||||
} else {
|
|
||||||
self.activateFlowSessionAfterPiPProof(duration: duration)
|
|
||||||
self.handleColdStartAfterSessionReady()
|
|
||||||
}
|
|
||||||
case .failed(let failure):
|
|
||||||
self.sessionWarning = AppL10n.string(failure.localizationKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bindSessionASRIfNeeded(force: Bool = false) {
|
private func bindSessionASRIfNeeded(force: Bool = false) {
|
||||||
@@ -1245,6 +1501,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
currentUtteranceMode = command.resolvedUtteranceMode
|
currentUtteranceMode = command.resolvedUtteranceMode
|
||||||
|
pendingManagedRequestPurpose = command.managedRequestPurpose
|
||||||
pendingAIConversationID = currentUtteranceMode == .aiQuestion
|
pendingAIConversationID = currentUtteranceMode == .aiQuestion
|
||||||
? command.aiConversationID
|
? command.aiConversationID
|
||||||
: nil
|
: nil
|
||||||
@@ -1357,6 +1614,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
commandSeq: command.commandSeq
|
commandSeq: command.commandSeq
|
||||||
) else { return }
|
) else { return }
|
||||||
currentUtteranceMode = .aiQuestion
|
currentUtteranceMode = .aiQuestion
|
||||||
|
pendingManagedRequestPurpose = command.managedRequestPurpose
|
||||||
pendingAIConversationID = command.aiConversationID
|
pendingAIConversationID = command.aiConversationID
|
||||||
pendingEditSourceText = nil
|
pendingEditSourceText = nil
|
||||||
pendingSourceHistoryEntryID = nil
|
pendingSourceHistoryEntryID = nil
|
||||||
@@ -1435,7 +1693,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations,
|
conversations: aiConversations,
|
||||||
taskKind: request.taskKind,
|
taskKind: request.taskKind,
|
||||||
thinkingEnabled: request.thinkingEnabled
|
thinkingEnabled: request.thinkingEnabled,
|
||||||
|
analyticsClient: analyticsClient,
|
||||||
|
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
|
||||||
)
|
)
|
||||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
let answer = try await service.answer(
|
let answer = try await service.answer(
|
||||||
@@ -1610,6 +1870,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
||||||
status: FlowResult.Status = .error
|
status: FlowResult.Status = .error
|
||||||
) {
|
) {
|
||||||
|
finishTranscriptionAnalytics(
|
||||||
|
success: false,
|
||||||
|
failureCategory: Self.transcriptionFailureCategory(
|
||||||
|
kind: kind,
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
)
|
||||||
guard let activeSessionId, let currentUtteranceId else { return }
|
guard let activeSessionId, let currentUtteranceId else { return }
|
||||||
FlowSessionBridge.writeResult(
|
FlowSessionBridge.writeResult(
|
||||||
FlowResult(
|
FlowResult(
|
||||||
@@ -1901,6 +2168,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let cloudASRForStreaming = useStreaming ? cloudService : nil
|
let cloudASRForStreaming = useStreaming ? cloudService : nil
|
||||||
|
transcriptionAnalyticsOperation?.cancel()
|
||||||
|
transcriptionAnalyticsOperation = analyticsClient.startAIFeature(
|
||||||
|
.transcription,
|
||||||
|
executionMode: analyticsExecutionMode
|
||||||
|
)
|
||||||
|
|
||||||
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
||||||
let outcome: ChunkedUtterancePipelineOutcome
|
let outcome: ChunkedUtterancePipelineOutcome
|
||||||
@@ -2220,6 +2492,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
pendingSourceHistoryEntryID = nil
|
pendingSourceHistoryEntryID = nil
|
||||||
pendingSourceHistoryEntryRevision = nil
|
pendingSourceHistoryEntryRevision = nil
|
||||||
pendingAIConversationID = nil
|
pendingAIConversationID = nil
|
||||||
|
pendingManagedRequestPurpose = nil
|
||||||
pendingProcessingDeadlineAt = nil
|
pendingProcessingDeadlineAt = nil
|
||||||
currentUtteranceMode = .dictation
|
currentUtteranceMode = .dictation
|
||||||
}
|
}
|
||||||
@@ -2237,6 +2510,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let sourceHistoryEntryID = pendingSourceHistoryEntryID
|
let sourceHistoryEntryID = pendingSourceHistoryEntryID
|
||||||
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
|
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
|
||||||
let aiConversationID = pendingAIConversationID
|
let aiConversationID = pendingAIConversationID
|
||||||
|
let managedRequestPurpose = pendingManagedRequestPurpose
|
||||||
let processingDeadlineAt = pendingProcessingDeadlineAt
|
let processingDeadlineAt = pendingProcessingDeadlineAt
|
||||||
let asrEngineMode = sessionASREngineMode ?? store.engineMode
|
let asrEngineMode = sessionASREngineMode ?? store.engineMode
|
||||||
// ALWAYS clear the processing gate for this utterance. The previous
|
// ALWAYS clear the processing gate for this utterance. The previous
|
||||||
@@ -2250,6 +2524,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
pendingSourceHistoryEntryID = nil
|
pendingSourceHistoryEntryID = nil
|
||||||
pendingSourceHistoryEntryRevision = nil
|
pendingSourceHistoryEntryRevision = nil
|
||||||
pendingAIConversationID = nil
|
pendingAIConversationID = nil
|
||||||
|
pendingManagedRequestPurpose = nil
|
||||||
pendingProcessingDeadlineAt = nil
|
pendingProcessingDeadlineAt = nil
|
||||||
if currentUtteranceMode == utteranceMode {
|
if currentUtteranceMode == utteranceMode {
|
||||||
currentUtteranceMode = .dictation
|
currentUtteranceMode = .dictation
|
||||||
@@ -2366,6 +2641,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
finishTranscriptionAnalytics(success: true)
|
||||||
|
|
||||||
// Re-read App Group at finalize so dictionary and translation changes
|
// Re-read App Group at finalize so dictionary and translation changes
|
||||||
// from the keyboard extension are visible before local correction,
|
// from the keyboard extension are visible before local correction,
|
||||||
@@ -2427,7 +2703,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let service = try AIQuestionService.configured(
|
let service = try AIQuestionService.configured(
|
||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations,
|
conversations: aiConversations,
|
||||||
taskKind: .aiQuestion
|
taskKind: .aiQuestion,
|
||||||
|
analyticsClient: analyticsClient,
|
||||||
|
analyticsFeature: .aiAssistant
|
||||||
)
|
)
|
||||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
let publishUtteranceId = finalizeUtteranceId
|
let publishUtteranceId = finalizeUtteranceId
|
||||||
@@ -2564,6 +2842,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
systemPrompt: instructionPrompt?.system,
|
systemPrompt: instructionPrompt?.system,
|
||||||
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
||||||
taskKind: isEditLastInput ? .editLastInput : nil,
|
taskKind: isEditLastInput ? .editLastInput : nil,
|
||||||
|
requestPurpose: managedRequestPurpose,
|
||||||
context: isInstructionMode ? nil : polishContext,
|
context: isInstructionMode ? nil : polishContext,
|
||||||
timeoutLimit: processingDeadlineAt.map {
|
timeoutLimit: processingDeadlineAt.map {
|
||||||
max(0.1, $0 - Date().timeIntervalSince1970)
|
max(0.1, $0 - Date().timeIntervalSince1970)
|
||||||
@@ -2868,6 +3147,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
status: FlowResult.Status = .error,
|
status: FlowResult.Status = .error,
|
||||||
aiConversationID: UUID? = nil
|
aiConversationID: UUID? = nil
|
||||||
) {
|
) {
|
||||||
|
finishTranscriptionAnalytics(
|
||||||
|
success: false,
|
||||||
|
failureCategory: Self.transcriptionFailureCategory(
|
||||||
|
kind: kind,
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
)
|
||||||
guard let sessionId, let utteranceId else { return }
|
guard let sessionId, let utteranceId else { return }
|
||||||
FlowTrace.warn(
|
FlowTrace.warn(
|
||||||
"host.deliveredError",
|
"host.deliveredError",
|
||||||
@@ -2892,6 +3178,59 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var analyticsExecutionMode: AnalyticsExecutionMode {
|
||||||
|
if store.engineMode == "local" {
|
||||||
|
return .local
|
||||||
|
}
|
||||||
|
return store.credentialSource == .managed ? .managed : .byok
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishTranscriptionAnalytics(
|
||||||
|
success: Bool,
|
||||||
|
failureCategory: AnalyticsFailureCategory = .unknown
|
||||||
|
) {
|
||||||
|
guard let operation = transcriptionAnalyticsOperation else { return }
|
||||||
|
transcriptionAnalyticsOperation = nil
|
||||||
|
if success {
|
||||||
|
operation.succeed()
|
||||||
|
} else {
|
||||||
|
operation.fail(category: failureCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func transcriptionFailureCategory(
|
||||||
|
kind: FlowSessionKeys.TranscriptionErrorKind,
|
||||||
|
status: FlowResult.Status
|
||||||
|
) -> AnalyticsFailureCategory {
|
||||||
|
if status == .aborted {
|
||||||
|
return .cancelled
|
||||||
|
}
|
||||||
|
if status == .timeout {
|
||||||
|
return .timeout
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case .noSpeech, .discardedEmpty:
|
||||||
|
return .validation
|
||||||
|
case .recognitionInterrupted:
|
||||||
|
return .cancelled
|
||||||
|
case .asrFailed, .audioUnavailable, .generic:
|
||||||
|
return .provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static var lastResultRevision: Int64 = 0
|
private static var lastResultRevision: Int64 = 0
|
||||||
|
|
||||||
private static func resultRevision() -> Int64 {
|
private static func resultRevision() -> Int64 {
|
||||||
@@ -3030,6 +3369,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
systemPrompt: String? = nil,
|
systemPrompt: String? = nil,
|
||||||
providerIdOverride: String?,
|
providerIdOverride: String?,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
context: PolishContext?,
|
context: PolishContext?,
|
||||||
timeoutLimit: TimeInterval? = nil
|
timeoutLimit: TimeInterval? = nil
|
||||||
) async throws -> PolishingService.PolishOutcome {
|
) async throws -> PolishingService.PolishOutcome {
|
||||||
@@ -3042,6 +3382,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
providerIdOverride: providerIdOverride,
|
providerIdOverride: providerIdOverride,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3081,6 +3422,14 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
self.isUtteranceRecording || self.isUtteranceProcessing {
|
self.isUtteranceRecording || self.isUtteranceProcessing {
|
||||||
await self.reactivateCaptureIfNeeded()
|
await self.reactivateCaptureIfNeeded()
|
||||||
}
|
}
|
||||||
|
if self.wantsActiveSession,
|
||||||
|
!self.pipController.isPictureInPictureActive {
|
||||||
|
if self.isAppForeground {
|
||||||
|
self.ensurePiPActive(trigger: .healthCheck, duration: nil)
|
||||||
|
} else {
|
||||||
|
self.pipLifecycleState = .waitingForForeground
|
||||||
|
}
|
||||||
|
}
|
||||||
FlowSessionBridge.writeHeartbeat()
|
FlowSessionBridge.writeHeartbeat()
|
||||||
self.refreshHostReady()
|
self.refreshHostReady()
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
@@ -3123,6 +3472,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
"event=\(event)",
|
"event=\(event)",
|
||||||
"active=\(isActive)",
|
"active=\(isActive)",
|
||||||
"starting=\(isStarting)",
|
"starting=\(isStarting)",
|
||||||
|
"wantsActive=\(wantsActiveSession)",
|
||||||
|
"pipState=\(String(describing: pipLifecycleState))",
|
||||||
|
"pipGeneration=\(pipController.generation)",
|
||||||
"coldStart=\(isColdStartHandoff)",
|
"coldStart=\(isColdStartHandoff)",
|
||||||
"sessionId=\(sessionId)",
|
"sessionId=\(sessionId)",
|
||||||
"utteranceId=\(utteranceId)",
|
"utteranceId=\(utteranceId)",
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ private struct AccountCreditsSection: View {
|
|||||||
private struct AccountCreditPurchaseSection: View {
|
private struct AccountCreditPurchaseSection: View {
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@ObservedObject var manager: AccountCreditPurchaseManager
|
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||||
|
@State private var didRecordPurchaseView = false
|
||||||
|
|
||||||
let accountID: UUID
|
let accountID: UUID
|
||||||
let onGranted: () -> Void
|
let onGranted: () -> Void
|
||||||
@@ -540,6 +541,11 @@ private struct AccountCreditPurchaseSection: View {
|
|||||||
guard current != nil, current != previous else { return }
|
guard current != nil, current != previous else { return }
|
||||||
onGranted()
|
onGranted()
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard !didRecordPurchaseView else { return }
|
||||||
|
didRecordPurchaseView = true
|
||||||
|
manager.recordPurchaseViewed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func purchaseOption(_ option: AccountCreditPurchaseOption) -> some View {
|
private func purchaseOption(_ option: AccountCreditPurchaseOption) -> some View {
|
||||||
@@ -777,10 +783,17 @@ private struct AccountInvitationActivityView: UIViewControllerRepresentable {
|
|||||||
let invitationURL: URL
|
let invitationURL: URL
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
UIActivityViewController(
|
let controller = UIActivityViewController(
|
||||||
activityItems: [invitationURL],
|
activityItems: [invitationURL],
|
||||||
applicationActivities: nil
|
applicationActivities: nil
|
||||||
)
|
)
|
||||||
|
controller.completionWithItemsHandler = { _, completed, _, _ in
|
||||||
|
guard completed else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
AnalyticsHostService.shared.client.recordReferralShared()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIViewController(
|
func updateUIViewController(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
struct AccountCreditPurchaseOption: Identifiable, Equatable {
|
struct AccountCreditPurchaseOption: Identifiable, Equatable {
|
||||||
var id: String { productID }
|
var id: String { productID }
|
||||||
@@ -43,6 +44,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
|
|
||||||
private let service: any AccountCenterServicing
|
private let service: any AccountCenterServicing
|
||||||
private let store: any AccountCreditStore
|
private let store: any AccountCreditStore
|
||||||
|
private let analyticsClient: any AnalyticsClient
|
||||||
private var updatesTask: Task<Void, Never>?
|
private var updatesTask: Task<Void, Never>?
|
||||||
private var activeAccountID: UUID?
|
private var activeAccountID: UUID?
|
||||||
private var configuredProductIDs: Set<String> = []
|
private var configuredProductIDs: Set<String> = []
|
||||||
@@ -50,10 +52,12 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
service: any AccountCenterServicing,
|
service: any AccountCenterServicing,
|
||||||
store: any AccountCreditStore = LiveAccountCreditStore()
|
store: any AccountCreditStore = LiveAccountCreditStore(),
|
||||||
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
|
||||||
) {
|
) {
|
||||||
self.service = service
|
self.service = service
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.analyticsClient = analyticsClient
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -100,6 +104,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
state = .purchasing(productID: productID)
|
state = .purchasing(productID: productID)
|
||||||
|
analyticsClient.recordPurchaseStarted()
|
||||||
do {
|
do {
|
||||||
switch try await store.purchase(productID: productID, accountID: accountID) {
|
switch try await store.purchase(productID: productID, accountID: accountID) {
|
||||||
case .success(let verification):
|
case .success(let verification):
|
||||||
@@ -109,6 +114,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
state = .pending
|
state = .pending
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
state = .idle
|
state = .idle
|
||||||
|
analyticsClient.recordPurchaseCancelled()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
state = .failed(messageKey: "account.storekit.error.unknown")
|
state = .failed(messageKey: "account.storekit.error.unknown")
|
||||||
}
|
}
|
||||||
@@ -118,6 +124,10 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func recordPurchaseViewed() {
|
||||||
|
analyticsClient.recordPurchaseViewed()
|
||||||
|
}
|
||||||
|
|
||||||
func clearTransientState() {
|
func clearTransientState() {
|
||||||
switch state {
|
switch state {
|
||||||
case .succeeded, .failed, .pending:
|
case .succeeded, .failed, .pending:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AccountSessionCoordinator: ObservableObject {
|
final class AccountSessionCoordinator: ObservableObject {
|
||||||
@@ -46,6 +47,9 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
private let sessionService: any AccountSessionServicing
|
private let sessionService: any AccountSessionServicing
|
||||||
private let centerService: any AccountCenterServicing
|
private let centerService: any AccountCenterServicing
|
||||||
private let pendingReferralStore: any PendingReferralCodeStoring
|
private let pendingReferralStore: any PendingReferralCodeStoring
|
||||||
|
private let analyticsClient: any AnalyticsClient
|
||||||
|
private let onAccountAuthenticated: (UUID) async -> Void
|
||||||
|
private let onAccountDeleted: () async -> Void
|
||||||
private let accountRefreshInterval: TimeInterval
|
private let accountRefreshInterval: TimeInterval
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
private var didAttemptRestore = false
|
private var didAttemptRestore = false
|
||||||
@@ -57,15 +61,22 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
pendingReferralStore: any PendingReferralCodeStoring =
|
pendingReferralStore: any PendingReferralCodeStoring =
|
||||||
UserDefaultsPendingReferralCodeStore(),
|
UserDefaultsPendingReferralCodeStore(),
|
||||||
accountRefreshInterval: TimeInterval = 10 * 60,
|
accountRefreshInterval: TimeInterval = 10 * 60,
|
||||||
now: @escaping () -> Date = Date.init
|
now: @escaping () -> Date = Date.init,
|
||||||
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
|
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
|
||||||
|
onAccountDeleted: @escaping () async -> Void = {}
|
||||||
) {
|
) {
|
||||||
sessionService = dependencies.sessionService
|
sessionService = dependencies.sessionService
|
||||||
centerService = dependencies.centerService
|
centerService = dependencies.centerService
|
||||||
creditPurchases = AccountCreditPurchaseManager(
|
creditPurchases = AccountCreditPurchaseManager(
|
||||||
service: dependencies.centerService,
|
service: dependencies.centerService,
|
||||||
store: creditStore
|
store: creditStore,
|
||||||
|
analyticsClient: analyticsClient
|
||||||
)
|
)
|
||||||
self.pendingReferralStore = pendingReferralStore
|
self.pendingReferralStore = pendingReferralStore
|
||||||
|
self.analyticsClient = analyticsClient
|
||||||
|
self.onAccountAuthenticated = onAccountAuthenticated
|
||||||
|
self.onAccountDeleted = onAccountDeleted
|
||||||
self.accountRefreshInterval = accountRefreshInterval
|
self.accountRefreshInterval = accountRefreshInterval
|
||||||
self.now = now
|
self.now = now
|
||||||
pendingReferralCode = pendingReferralStore.code
|
pendingReferralCode = pendingReferralStore.code
|
||||||
@@ -94,6 +105,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
sessionPhase = .signedOut
|
sessionPhase = .signedOut
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await onAccountAuthenticated(session.accountID)
|
||||||
sessionPhase = .signedIn(session)
|
sessionPhase = .signedIn(session)
|
||||||
await redeemPendingReferralIfNeeded()
|
await redeemPendingReferralIfNeeded()
|
||||||
await refreshAccountData(force: true)
|
await refreshAccountData(force: true)
|
||||||
@@ -114,6 +126,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
pendingReferralCode = code
|
pendingReferralCode = code
|
||||||
shouldPresentAccountCenter = true
|
shouldPresentAccountCenter = true
|
||||||
operationErrorKey = nil
|
operationErrorKey = nil
|
||||||
|
analyticsClient.recordInviteOpened()
|
||||||
|
|
||||||
if isSignedIn {
|
if isSignedIn {
|
||||||
Task {
|
Task {
|
||||||
@@ -138,6 +151,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let session = try await sessionService.signIn(with: payload)
|
let session = try await sessionService.signIn(with: payload)
|
||||||
|
await onAccountAuthenticated(session.accountID)
|
||||||
sessionPhase = .signedIn(session)
|
sessionPhase = .signedIn(session)
|
||||||
await redeemPendingReferralIfNeeded()
|
await redeemPendingReferralIfNeeded()
|
||||||
await refreshAccountData(force: true)
|
await refreshAccountData(force: true)
|
||||||
@@ -260,6 +274,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await sessionService.deleteAccount(with: payload)
|
try await sessionService.deleteAccount(with: payload)
|
||||||
|
await onAccountDeleted()
|
||||||
creditPurchases.reset()
|
creditPurchases.reset()
|
||||||
clearAccountRefreshState()
|
clearAccountRefreshState()
|
||||||
sessionPhase = .signedOut
|
sessionPhase = .signedOut
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ enum LiveAccountDependencyFactory {
|
|||||||
|
|
||||||
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
|
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
|
||||||
let apiClient = AccountAPIClient(sessionVault: sessionVault)
|
let apiClient = AccountAPIClient(sessionVault: sessionVault)
|
||||||
|
HostAnalyticsBearerBridge.shared.install(
|
||||||
|
AccountAnalyticsBearerProvider(apiClient: apiClient)
|
||||||
|
)
|
||||||
let integrity = DeviceIntegrityCoordinator(
|
let integrity = DeviceIntegrityCoordinator(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
keyStateStore: sessionVault
|
keyStateStore: sessionVault
|
||||||
|
|||||||
@@ -14,6 +14,68 @@ private enum HomeRoute: Hashable {
|
|||||||
case dictionary
|
case dictionary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FlowHomePiPStatusDescriptor: Equatable {
|
||||||
|
case text(localizationKey: String)
|
||||||
|
case progress(localizationKey: String, attempt: Int, total: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlowHomePiPStatusPolicy {
|
||||||
|
static func descriptor(
|
||||||
|
lifecycle: FlowPiPLifecycleState,
|
||||||
|
isStarting: Bool,
|
||||||
|
isRecording: Bool,
|
||||||
|
isProcessing: Bool,
|
||||||
|
isActive: Bool,
|
||||||
|
isHostReady: Bool
|
||||||
|
) -> FlowHomePiPStatusDescriptor {
|
||||||
|
switch lifecycle {
|
||||||
|
case .preparing(let attempt, let total):
|
||||||
|
return .progress(
|
||||||
|
localizationKey: "home.flow.preparingProgress",
|
||||||
|
attempt: attempt,
|
||||||
|
total: total
|
||||||
|
)
|
||||||
|
case .recovering(let attempt, let total):
|
||||||
|
return .progress(
|
||||||
|
localizationKey: "home.flow.recoveringProgress",
|
||||||
|
attempt: attempt,
|
||||||
|
total: total
|
||||||
|
)
|
||||||
|
case .waitingForForeground:
|
||||||
|
return .text(localizationKey: "home.flow.waitingForForeground")
|
||||||
|
case .failed:
|
||||||
|
return .text(localizationKey: "home.flow.recoveryFailed")
|
||||||
|
case .inactive, .active:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if isStarting {
|
||||||
|
return .text(localizationKey: "home.flow.starting")
|
||||||
|
}
|
||||||
|
if isRecording {
|
||||||
|
return .text(localizationKey: "home.flow.recording")
|
||||||
|
}
|
||||||
|
if isProcessing {
|
||||||
|
return .text(localizationKey: "home.flow.processing")
|
||||||
|
}
|
||||||
|
if isActive, isHostReady {
|
||||||
|
return .text(localizationKey: "home.flow.label")
|
||||||
|
}
|
||||||
|
if isActive {
|
||||||
|
// Session flag is up but the ready contract is not — do not lie.
|
||||||
|
return .text(localizationKey: "home.flow.notReady")
|
||||||
|
}
|
||||||
|
return .text(localizationKey: "home.flow.inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func canRetry(
|
||||||
|
lifecycle: FlowPiPLifecycleState,
|
||||||
|
needsPermissionSetup: Bool
|
||||||
|
) -> Bool {
|
||||||
|
guard case .failed = lifecycle else { return false }
|
||||||
|
return !needsPermissionSetup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@@ -55,6 +117,23 @@ struct HomeView: View {
|
|||||||
!sessionIsLive && !needsPermissionSetup
|
!sessionIsLive && !needsPermissionSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canRetryPiP: Bool {
|
||||||
|
FlowHomePiPStatusPolicy.canRetry(
|
||||||
|
lifecycle: flowManager.pipLifecycleState,
|
||||||
|
needsPermissionSetup: needsPermissionSetup
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canEndFlowSession: Bool {
|
||||||
|
if flowManager.isActive || flowManager.isStarting { return true }
|
||||||
|
switch flowManager.pipLifecycleState {
|
||||||
|
case .waitingForForeground, .failed:
|
||||||
|
return true
|
||||||
|
case .inactive, .preparing, .recovering, .active:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var shouldShowKeyboardHint: Bool {
|
private var shouldShowKeyboardHint: Bool {
|
||||||
!keyboardHintDismissed
|
!keyboardHintDismissed
|
||||||
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
|
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
|
||||||
@@ -125,11 +204,11 @@ struct HomeView: View {
|
|||||||
.padding(.top, logoTopPadding)
|
.padding(.top, logoTopPadding)
|
||||||
.padding(.bottom, logoBottomPadding)
|
.padding(.bottom, logoBottomPadding)
|
||||||
|
|
||||||
if showsFlowSessionExtras {
|
// Engine + Flow status now stays directly below the
|
||||||
flowSessionExtras
|
// logo so startup and recovery are always visible.
|
||||||
|
scrollStatusFooter
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
.padding(.bottom, extrasBottomPadding)
|
.padding(.bottom, extrasBottomPadding)
|
||||||
}
|
|
||||||
|
|
||||||
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
|
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
@@ -138,11 +217,6 @@ struct HomeView: View {
|
|||||||
homeLibrarySection
|
homeLibrarySection
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
.padding(.bottom, Spacing.xl)
|
.padding(.bottom, Spacing.xl)
|
||||||
|
|
||||||
// Scrolls with the page (not pinned); clearance comes from
|
|
||||||
// `tabBarScrollBottomPadding` so the dock never covers it.
|
|
||||||
scrollStatusFooter
|
|
||||||
.padding(.horizontal, Spacing.lg)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.tabBarScrollBottomPadding()
|
.tabBarScrollBottomPadding()
|
||||||
@@ -152,14 +226,14 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Engine + Flow status — last content in the scroll stack.
|
/// Engine + Flow status — visible near the logo instead of at scroll bottom.
|
||||||
private var scrollStatusFooter: some View {
|
private var scrollStatusFooter: some View {
|
||||||
VStack(spacing: Spacing.xs) {
|
setupGuidanceCard {
|
||||||
engineStatusLine
|
engineStatusLine
|
||||||
flowStatusFooter
|
flowStatusFooter
|
||||||
|
Divider()
|
||||||
|
flowSessionExtras
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wide layout (iPad / regular width)
|
// MARK: - Wide layout (iPad / regular width)
|
||||||
@@ -169,16 +243,11 @@ struct HomeView: View {
|
|||||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||||
wideHeroHeader
|
wideHeroHeader
|
||||||
|
|
||||||
if showsFlowSessionExtras {
|
scrollStatusFooter
|
||||||
flowSessionExtras
|
|
||||||
}
|
|
||||||
|
|
||||||
HomeUsageStatsSection(layout: .split)
|
HomeUsageStatsSection(layout: .split)
|
||||||
|
|
||||||
homeLibrarySection
|
homeLibrarySection
|
||||||
|
|
||||||
scrollStatusFooter
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||||
.padding(.top, Spacing.sm)
|
.padding(.top, Spacing.sm)
|
||||||
@@ -466,7 +535,26 @@ struct HomeView: View {
|
|||||||
if needsAPIKeySetup {
|
if needsAPIKeySetup {
|
||||||
// 无按钮:引导卡片已提示去设置填 API Key。
|
// 无按钮:引导卡片已提示去设置填 API Key。
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else if flowManager.isActive {
|
} else if canRetryPiP {
|
||||||
|
Button {
|
||||||
|
flowManager.retryPiPRecovery()
|
||||||
|
} label: {
|
||||||
|
Text("home.flow.retry")
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.leading, Spacing.xs)
|
||||||
|
Button {
|
||||||
|
flowManager.endSession()
|
||||||
|
} label: {
|
||||||
|
Text("home.flow.endShort")
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.leading, Spacing.xs)
|
||||||
|
} else if canEndFlowSession {
|
||||||
Button {
|
Button {
|
||||||
flowManager.endSession()
|
flowManager.endSession()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -477,8 +565,8 @@ struct HomeView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, Spacing.xs)
|
.padding(.leading, Spacing.xs)
|
||||||
} else if canManuallyStartSession {
|
} else if canManuallyStartSession {
|
||||||
// Sessions no longer auto-start on foreground (keyboard survival).
|
// Foreground activation is automatic. After an explicit stop
|
||||||
// After a manual stop / expiry, Start is the explicit re-entry.
|
// while staying on this screen, Start is the explicit re-entry.
|
||||||
Button {
|
Button {
|
||||||
flowManager.activateOnForeground(
|
flowManager.activateOnForeground(
|
||||||
reason: "HomeView.startButton",
|
reason: "HomeView.startButton",
|
||||||
@@ -498,18 +586,10 @@ struct HomeView: View {
|
|||||||
|
|
||||||
// MARK: - Flow extras (warnings / hints)
|
// MARK: - Flow extras (warnings / hints)
|
||||||
|
|
||||||
private var showsFlowSessionExtras: Bool {
|
|
||||||
needsPermissionSetup
|
|
||||||
|| flowManager.sessionWarning != nil
|
|
||||||
|| needsAPIKeySetup
|
|
||||||
|| shouldShowKeyboardHint
|
|
||||||
|| !flowManager.isActive
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var flowSessionExtras: some View {
|
private var flowSessionExtras: some View {
|
||||||
if needsPermissionSetup {
|
if needsPermissionSetup {
|
||||||
setupGuidanceCard {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
Text(AppPermissions.homePermissionGuidanceMessage)
|
Text(AppPermissions.homePermissionGuidanceMessage)
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.warning)
|
.foregroundStyle(palette.warning)
|
||||||
@@ -526,14 +606,14 @@ struct HomeView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
} else if let warning = flowManager.sessionWarning {
|
} else if let warning = flowManager.sessionWarning {
|
||||||
setupGuidanceCard {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
Text(warning)
|
Text(warning)
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.warning)
|
.foregroundStyle(palette.warning)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
} else if needsAPIKeySetup {
|
} else if needsAPIKeySetup {
|
||||||
setupGuidanceCard {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
Text(config.isLocalEngine
|
Text(config.isLocalEngine
|
||||||
? "home.setup.polishKeyMissing"
|
? "home.setup.polishKeyMissing"
|
||||||
: "home.setup.cloudIncomplete")
|
: "home.setup.cloudIncomplete")
|
||||||
@@ -542,7 +622,7 @@ struct HomeView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
} else if shouldShowKeyboardHint {
|
} else if shouldShowKeyboardHint {
|
||||||
setupGuidanceCard {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
Text("home.setup.keyboardHint")
|
Text("home.setup.keyboardHint")
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
@@ -584,6 +664,8 @@ struct HomeView: View {
|
|||||||
if needsAPIKeySetup { return palette.warning }
|
if needsAPIKeySetup { return palette.warning }
|
||||||
if flowManager.isUtteranceRecording { return palette.accent }
|
if flowManager.isUtteranceRecording { return palette.accent }
|
||||||
if flowManager.isUtteranceProcessing { return palette.accent }
|
if flowManager.isUtteranceProcessing { return palette.accent }
|
||||||
|
if case .failed = flowManager.pipLifecycleState { return palette.warning }
|
||||||
|
if case .waitingForForeground = flowManager.pipLifecycleState { return palette.warning }
|
||||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||||
if flowManager.isStarting { return palette.accent }
|
if flowManager.isStarting { return palette.accent }
|
||||||
if needsPermissionSetup { return palette.warning }
|
if needsPermissionSetup { return palette.warning }
|
||||||
@@ -598,23 +680,20 @@ struct HomeView: View {
|
|||||||
/// with the OS), so the previous downloading / warming / failed
|
/// with the OS), so the previous downloading / warming / failed
|
||||||
/// states collapse into the cloud-engine branch.
|
/// states collapse into the cloud-engine branch.
|
||||||
private var flowCapsuleStatusMessage: String {
|
private var flowCapsuleStatusMessage: String {
|
||||||
if flowManager.isStarting {
|
let descriptor = FlowHomePiPStatusPolicy.descriptor(
|
||||||
return AppL10n.string("home.flow.starting")
|
lifecycle: flowManager.pipLifecycleState,
|
||||||
|
isStarting: flowManager.isStarting,
|
||||||
|
isRecording: flowManager.isUtteranceRecording,
|
||||||
|
isProcessing: flowManager.isUtteranceProcessing,
|
||||||
|
isActive: flowManager.isActive,
|
||||||
|
isHostReady: FlowSessionBridge.isHostReady()
|
||||||
|
)
|
||||||
|
switch descriptor {
|
||||||
|
case .text(let localizationKey):
|
||||||
|
return AppL10n.string(localizationKey)
|
||||||
|
case .progress(let localizationKey, let attempt, let total):
|
||||||
|
return AppL10n.format(localizationKey, attempt, total)
|
||||||
}
|
}
|
||||||
if flowManager.isUtteranceRecording {
|
|
||||||
return AppL10n.string("home.flow.recording")
|
|
||||||
}
|
|
||||||
if flowManager.isUtteranceProcessing {
|
|
||||||
return AppL10n.string("home.flow.processing")
|
|
||||||
}
|
|
||||||
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
|
||||||
return AppL10n.string("home.flow.label")
|
|
||||||
}
|
|
||||||
if flowManager.isActive {
|
|
||||||
// Session flag is up but the ready contract is not — do not lie.
|
|
||||||
return AppL10n.string("home.flow.notReady")
|
|
||||||
}
|
|
||||||
return AppL10n.string("home.flow.inactive")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var engineStatusLine: some View {
|
private var engineStatusLine: some View {
|
||||||
|
|||||||
@@ -16,15 +16,30 @@ struct MainAppRoot: View {
|
|||||||
// `@ObservedObject` keeps subscriptions correct across Settings replay.
|
// `@ObservedObject` keeps subscriptions correct across Settings replay.
|
||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
|
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
|
||||||
@StateObject private var flowManager = FlowSessionManager()
|
@ObservedObject private var analytics = AnalyticsHostService.shared
|
||||||
|
@StateObject private var flowManager: FlowSessionManager
|
||||||
@StateObject private var accountSession: AccountSessionCoordinator
|
@StateObject private var accountSession: AccountSessionCoordinator
|
||||||
@State private var clmWarmupTask: Task<Void, Never>?
|
@State private var clmWarmupTask: Task<Void, Never>?
|
||||||
@State private var rimeStartupTask: Task<Void, Never>?
|
@State private var rimeStartupTask: Task<Void, Never>?
|
||||||
|
@State private var firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown
|
||||||
|
|
||||||
init(accountDependencies: AccountDependencies? = nil) {
|
init(accountDependencies: AccountDependencies? = nil) {
|
||||||
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
||||||
|
let analytics = AnalyticsHostService.shared
|
||||||
|
_flowManager = StateObject(
|
||||||
|
wrappedValue: FlowSessionManager(analyticsClient: analytics.client)
|
||||||
|
)
|
||||||
_accountSession = StateObject(
|
_accountSession = StateObject(
|
||||||
wrappedValue: AccountSessionCoordinator(dependencies: resolvedDependencies)
|
wrappedValue: AccountSessionCoordinator(
|
||||||
|
dependencies: resolvedDependencies,
|
||||||
|
analyticsClient: analytics.client,
|
||||||
|
onAccountAuthenticated: { accountID in
|
||||||
|
await analytics.observeAuthenticatedAccount(accountID)
|
||||||
|
},
|
||||||
|
onAccountDeleted: {
|
||||||
|
await analytics.handleAccountDeletion()
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +50,7 @@ struct MainAppRoot: View {
|
|||||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||||
.environmentObject(flowManager)
|
.environmentObject(flowManager)
|
||||||
.environmentObject(accountSession)
|
.environmentObject(accountSession)
|
||||||
|
.environmentObject(analytics)
|
||||||
.background {
|
.background {
|
||||||
FlowPiPHostView { view in
|
FlowPiPHostView { view in
|
||||||
flowManager.attachPiPHostView(view)
|
flowManager.attachPiPHostView(view)
|
||||||
@@ -65,6 +81,9 @@ struct MainAppRoot: View {
|
|||||||
AppOpenURLRouter.shared.register { url in
|
AppOpenURLRouter.shared.register { url in
|
||||||
handleIncomingURL(url)
|
handleIncomingURL(url)
|
||||||
}
|
}
|
||||||
|
analytics.prepare(
|
||||||
|
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
|
||||||
|
)
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
|
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
|
||||||
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
|
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
|
||||||
@@ -131,6 +150,11 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
flowManager.handleScenePhase(phase)
|
flowManager.handleScenePhase(phase)
|
||||||
|
if phase == .active {
|
||||||
|
analytics.appDidBecomeActive()
|
||||||
|
} else if phase == .background {
|
||||||
|
analytics.appDidEnterBackground()
|
||||||
|
}
|
||||||
guard phase == .active else {
|
guard phase == .active else {
|
||||||
clmWarmupTask?.cancel()
|
clmWarmupTask?.cancel()
|
||||||
clmWarmupTask = nil
|
clmWarmupTask = nil
|
||||||
@@ -200,7 +224,7 @@ struct MainAppRoot: View {
|
|||||||
MainTabView()
|
MainTabView()
|
||||||
.id("main")
|
.id("main")
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(config: config)
|
OnboardingExperienceView(config: config)
|
||||||
.id("onboarding")
|
.id("onboarding")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +270,7 @@ struct MainAppRoot: View {
|
|||||||
|
|
||||||
private func handleIncomingURL(_ url: URL) {
|
private func handleIncomingURL(_ url: URL) {
|
||||||
if accountSession.handleIncomingURL(url) {
|
if accountSession.handleIncomingURL(url) {
|
||||||
|
firstOpenAcquisitionChannel = .referral
|
||||||
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -565,11 +565,35 @@ struct ClipboardSettingsView: View {
|
|||||||
struct AboutSettingsView: View {
|
struct AboutSettingsView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
|
@EnvironmentObject private var analytics: AnalyticsHostService
|
||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
CardPageContent {
|
CardPageContent {
|
||||||
|
CardSection("settings.analytics.section") {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Toggle(
|
||||||
|
"settings.analytics.title",
|
||||||
|
isOn: Binding(
|
||||||
|
get: { analytics.isEnabled },
|
||||||
|
set: { analytics.setEnabled($0) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.tint(palette.accent)
|
||||||
|
.settingsListRow()
|
||||||
|
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
|
Text("settings.analytics.description")
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.settingsListRow()
|
||||||
|
}
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
|
||||||
CardSection("settings.about.title") {
|
CardSection("settings.about.title") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Button {
|
Button {
|
||||||
@@ -637,5 +661,8 @@ struct AboutSettingsView: View {
|
|||||||
.navigationTitle("settings.about.title")
|
.navigationTitle("settings.about.title")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.hidesTabBarWhenPushed()
|
.hidesTabBarWhenPushed()
|
||||||
|
.onAppear {
|
||||||
|
analytics.refreshEnabledState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,58 @@
|
|||||||
"onboarding.polish.title" = "Text polish (LLM)";
|
"onboarding.polish.title" = "Text polish (LLM)";
|
||||||
"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider.";
|
"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider.";
|
||||||
"onboarding.polish.localHint" = "Fill in an API key to enable AI polish. Without a key, raw ASR text is inserted.";
|
"onboarding.polish.localHint" = "Fill in an API key to enable AI polish. Without a key, raw ASR text is inserted.";
|
||||||
|
"onboarding.experience.intro.eyebrow" = "AI AGENT VOICE KEYBOARD";
|
||||||
|
"onboarding.experience.intro.title" = "Speak. It’s written.";
|
||||||
|
"onboarding.experience.intro.subtitle" = "More than dictation. OSGKeyboard understands, writes, translates, and edits—then its AI Agent helps with what comes next.";
|
||||||
|
"onboarding.experience.intro.action" = "Get Started";
|
||||||
|
"onboarding.experience.privacy.local.title" = "On-device transcription available";
|
||||||
|
"onboarding.experience.privacy.local.body" = "With the local engine, speech recognition stays on your device.";
|
||||||
|
"onboarding.experience.privacy.cloud.title" = "Clear cloud boundaries";
|
||||||
|
"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.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.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.practice.editorTitle" = "New Message";
|
||||||
|
"onboarding.experience.practice.permissionsTitle" = "Voice access is still needed";
|
||||||
|
"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.switchSubtitle" = "Tap the field below, hold the globe key, then choose OSGKeyboard.";
|
||||||
|
"onboarding.experience.practice.fullAccessTitle" = "One more setting";
|
||||||
|
"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.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.dictate" = "Voice to Text";
|
||||||
|
"onboarding.experience.complete.translate" = "Translate";
|
||||||
|
"onboarding.experience.complete.agent" = "AI Agent";
|
||||||
|
"onboarding.experience.complete.edit" = "Voice Edit";
|
||||||
|
"onboarding.experience.complete.action" = "Start Using";
|
||||||
"settings.onboarding.replay" = "Restart permission setup";
|
"settings.onboarding.replay" = "Restart permission setup";
|
||||||
|
|
||||||
/* Common navigation */
|
/* Common navigation */
|
||||||
@@ -419,6 +471,11 @@
|
|||||||
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
|
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
|
||||||
"home.setup.keyboardHint.dismiss" = "Got it";
|
"home.setup.keyboardHint.dismiss" = "Got it";
|
||||||
"home.flow.starting" = "Starting voice session…";
|
"home.flow.starting" = "Starting voice session…";
|
||||||
|
"home.flow.preparingProgress" = "Preparing background session (%d/%d)…";
|
||||||
|
"home.flow.recoveringProgress" = "Restoring background session (%d/%d)…";
|
||||||
|
"home.flow.waitingForForeground" = "Background session paused — reopen this app to restore it";
|
||||||
|
"home.flow.recoveryFailed" = "Background session needs attention";
|
||||||
|
"home.flow.retry" = "Retry";
|
||||||
"home.flow.openSettings" = "Open Settings to grant permissions";
|
"home.flow.openSettings" = "Open Settings to grant permissions";
|
||||||
"home.flow.start" = "Start voice session";
|
"home.flow.start" = "Start voice session";
|
||||||
"home.flow.end" = "End voice session";
|
"home.flow.end" = "End voice session";
|
||||||
@@ -805,3 +862,6 @@
|
|||||||
"account.managed.accountCredits" = "Account credits";
|
"account.managed.accountCredits" = "Account credits";
|
||||||
"account.managed.explanation" = "This choice is independent from local or cloud speech recognition. Account sessions remain private to the host app; the keyboard receives only a short-lived, limited grant.";
|
"account.managed.explanation" = "This choice is independent from local or cloud speech recognition. Account sessions remain private to the host app; the keyboard receives only a short-lived, limited grant.";
|
||||||
"account.managed.preparing" = "Preparing managed access…";
|
"account.managed.preparing" = "Preparing managed access…";
|
||||||
|
"settings.analytics.section" = "Privacy";
|
||||||
|
"settings.analytics.title" = "Product Analytics";
|
||||||
|
"settings.analytics.description" = "Helps improve reliability with limited first-party events. Keyboard input, audio, transcripts, prompts, model output, credentials, and personal identifiers are never collected. Turning this off deletes queued events from this device.";
|
||||||
|
|||||||
@@ -36,6 +36,58 @@
|
|||||||
"onboarding.polish.title" = "文本润色(LLM)";
|
"onboarding.polish.title" = "文本润色(LLM)";
|
||||||
"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
|
"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
|
||||||
"onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。";
|
"onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。";
|
||||||
|
"onboarding.experience.intro.eyebrow" = "AI AGENT 语音输入法";
|
||||||
|
"onboarding.experience.intro.title" = "开口,即成文";
|
||||||
|
"onboarding.experience.intro.subtitle" = "不只是语音输入。OSGKeyboard 能听懂、成文、翻译和编辑,也能调用 AI Agent 帮你处理下一步。";
|
||||||
|
"onboarding.experience.intro.action" = "开始设置";
|
||||||
|
"onboarding.experience.privacy.local.title" = "支持设备端转写";
|
||||||
|
"onboarding.experience.privacy.local.body" = "使用本地引擎时,语音识别在你的设备上完成。";
|
||||||
|
"onboarding.experience.privacy.cloud.title" = "明确的云端边界";
|
||||||
|
"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.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
|
||||||
|
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
|
||||||
|
"onboarding.experience.permissions.later" = "暂时跳过";
|
||||||
|
"onboarding.experience.keyboard.title" = "添加 OSGKeyboard";
|
||||||
|
"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.practice.editorTitle" = "新消息";
|
||||||
|
"onboarding.experience.practice.permissionsTitle" = "还需要语音权限";
|
||||||
|
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
|
||||||
|
"onboarding.experience.practice.switchTitle" = "切换到 OSGKeyboard";
|
||||||
|
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
|
||||||
|
"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.complete.title" = "已经准备好了";
|
||||||
|
"onboarding.experience.complete.subtitle" = "以后在任何输入框切换到 OSGKeyboard,开口成文,也可以继续交给 AI Agent。";
|
||||||
|
"onboarding.experience.complete.dictate" = "语音成文";
|
||||||
|
"onboarding.experience.complete.translate" = "翻译";
|
||||||
|
"onboarding.experience.complete.agent" = "AI Agent";
|
||||||
|
"onboarding.experience.complete.edit" = "语音编辑";
|
||||||
|
"onboarding.experience.complete.action" = "开始使用";
|
||||||
"settings.onboarding.replay" = "重新开始权限引导";
|
"settings.onboarding.replay" = "重新开始权限引导";
|
||||||
|
|
||||||
/* Common navigation */
|
/* Common navigation */
|
||||||
@@ -418,6 +470,11 @@
|
|||||||
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
|
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
|
||||||
"home.setup.keyboardHint.dismiss" = "知道了";
|
"home.setup.keyboardHint.dismiss" = "知道了";
|
||||||
"home.flow.starting" = "正在启动语音会话…";
|
"home.flow.starting" = "正在启动语音会话…";
|
||||||
|
"home.flow.preparingProgress" = "正在准备后台会话(%d/%d)…";
|
||||||
|
"home.flow.recoveringProgress" = "正在恢复后台会话(%d/%d)…";
|
||||||
|
"home.flow.waitingForForeground" = "后台会话已暂停,重新打开本 App 后自动恢复";
|
||||||
|
"home.flow.recoveryFailed" = "后台会话需要恢复";
|
||||||
|
"home.flow.retry" = "重试";
|
||||||
"home.flow.openSettings" = "去设置打开权限";
|
"home.flow.openSettings" = "去设置打开权限";
|
||||||
"home.flow.start" = "启动语音会话";
|
"home.flow.start" = "启动语音会话";
|
||||||
"home.flow.end" = "结束语音会话";
|
"home.flow.end" = "结束语音会话";
|
||||||
@@ -804,3 +861,6 @@
|
|||||||
"account.managed.accountCredits" = "账号积分";
|
"account.managed.accountCredits" = "账号积分";
|
||||||
"account.managed.explanation" = "该选择与本地或云端语音识别相互独立。账号会话仅保存在主 App,键盘只能获得短时、受限的网关授权。";
|
"account.managed.explanation" = "该选择与本地或云端语音识别相互独立。账号会话仅保存在主 App,键盘只能获得短时、受限的网关授权。";
|
||||||
"account.managed.preparing" = "正在准备托管服务…";
|
"account.managed.preparing" = "正在准备托管服务…";
|
||||||
|
"settings.analytics.section" = "隐私";
|
||||||
|
"settings.analytics.title" = "产品分析";
|
||||||
|
"settings.analytics.description" = "通过有限的第一方事件帮助改进可靠性。绝不收集键盘输入、音频、转录、Prompt、模型输出、凭证或个人标识。关闭后会删除本机尚未上传的事件。";
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
AnalyticsExtensionService.shared.keyboardWillDisappear()
|
||||||
assistantFieldActionRefreshTask?.cancel()
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
assistantFieldActionRefreshTask = nil
|
assistantFieldActionRefreshTask = nil
|
||||||
clipboardCapture?.keyboardWillDisappear()
|
clipboardCapture?.keyboardWillDisappear()
|
||||||
@@ -305,6 +306,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewDidAppear(_ animated: Bool) {
|
public override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
AnalyticsExtensionService.shared.recordPresentation(
|
||||||
|
hasFullAccess: hasFullAccess
|
||||||
|
)
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
|
|||||||
@@ -5,7 +5,44 @@
|
|||||||
<key>NSPrivacyTracking</key>
|
<key>NSPrivacyTracking</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSPrivacyCollectedDataTypes</key>
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
<array/>
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeDeviceID</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeOtherUsageData</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>NSPrivacyAccessedAPITypes</key>
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// AnalyticsExtensionService.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// The extension records into the shared SQLite queue and only attempts one
|
||||||
|
// short anonymous batch when Full Access permits network use.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private actor ExtensionAnalyticsUploadSignal: AnalyticsUploadTriggering {
|
||||||
|
typealias Action = @Sendable () async -> Void
|
||||||
|
|
||||||
|
private var action: Action?
|
||||||
|
private var canUpload = false
|
||||||
|
private var isUploading = false
|
||||||
|
|
||||||
|
func install(_ action: @escaping Action) {
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCanUpload(_ canUpload: Bool) {
|
||||||
|
self.canUpload = canUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestUpload() {
|
||||||
|
guard canUpload, !isUploading, let action else { return }
|
||||||
|
isUploading = true
|
||||||
|
Task {
|
||||||
|
await action()
|
||||||
|
uploadFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadFinished() {
|
||||||
|
isUploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionAnalyticsLogger: AnalyticsLogging {
|
||||||
|
private let logger = Logger(
|
||||||
|
subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios.keyboard",
|
||||||
|
category: "analytics"
|
||||||
|
)
|
||||||
|
|
||||||
|
func log(_ entry: AnalyticsUploadLogEntry) {
|
||||||
|
let statusCode = entry.statusCode ?? 0
|
||||||
|
let errorCategory = entry.errorCategory?.rawValue ?? "none"
|
||||||
|
logger.info(
|
||||||
|
"outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AnalyticsExtensionService: Sendable {
|
||||||
|
static let shared = AnalyticsExtensionService()
|
||||||
|
|
||||||
|
let client: any AnalyticsClient
|
||||||
|
|
||||||
|
private let runtime: AnalyticsRuntime
|
||||||
|
private let uploadSignal: ExtensionAnalyticsUploadSignal
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let signal = ExtensionAnalyticsUploadSignal()
|
||||||
|
uploadSignal = signal
|
||||||
|
let runtime = AnalyticsRuntime.keyboardExtension(
|
||||||
|
environment: Self.environment,
|
||||||
|
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
|
||||||
|
trigger: signal,
|
||||||
|
logger: ExtensionAnalyticsLogger()
|
||||||
|
)
|
||||||
|
self.runtime = runtime
|
||||||
|
client = runtime.client
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await signal.install {
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordPresentation(hasFullAccess: Bool) {
|
||||||
|
Task {
|
||||||
|
await uploadSignal.setCanUpload(hasFullAccess)
|
||||||
|
client.recordSessionActivity()
|
||||||
|
client.recordKeyboardActivated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboardWillDisappear() {
|
||||||
|
Task {
|
||||||
|
await uploadSignal.setCanUpload(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let endpoint = URL(
|
||||||
|
string: "https://account.osglab.com/v1/analytics/events"
|
||||||
|
)!
|
||||||
|
|
||||||
|
private static var environment: AnalyticsEnvironment {
|
||||||
|
let appVersion = Bundle.main.object(
|
||||||
|
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||||
|
) as? String ?? "unknown"
|
||||||
|
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return AnalyticsEnvironment(
|
||||||
|
appVersion: appVersion,
|
||||||
|
osVersion: "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ final class KeyboardConfigSync {
|
|||||||
// Keychain fallback: a reboot must not resurrect the mic gate when
|
// Keychain fallback: a reboot must not resurrect the mic gate when
|
||||||
// App Group transiently reads empty.
|
// App Group transiently reads empty.
|
||||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
||||||
|
state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive
|
||||||
}
|
}
|
||||||
|
|
||||||
func persistLocale(_ id: String) {
|
func persistLocale(_ id: String) {
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ final class KeyboardFlowCoordinator {
|
|||||||
|| currentUtteranceRequest != nil
|
|| currentUtteranceRequest != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var voiceSetupReady: Bool {
|
||||||
|
state.hasCompletedOnboarding || state.isOnboardingPracticeActive
|
||||||
|
}
|
||||||
|
|
||||||
var isEditSessionActive: Bool { currentUtteranceRequest?.isEdit == true }
|
var isEditSessionActive: Bool { currentUtteranceRequest?.isEdit == true }
|
||||||
|
|
||||||
/// Session/transcription changes are pushed in real time by Darwin
|
/// Session/transcription changes are pushed in real time by Darwin
|
||||||
@@ -159,7 +163,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
/// noise used to call this again while `ready` briefly lagged).
|
/// noise used to call this again while `ready` briefly lagged).
|
||||||
func ensurePiPReadyOnKeyboardOpen() {
|
func ensurePiPReadyOnKeyboardOpen() {
|
||||||
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
|
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
|
||||||
state.hasCompletedOnboarding,
|
voiceSetupReady,
|
||||||
hasFullAccess(),
|
hasFullAccess(),
|
||||||
AppGroup.isAvailable,
|
AppGroup.isAvailable,
|
||||||
!isPendingFlowStart,
|
!isPendingFlowStart,
|
||||||
@@ -335,7 +339,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
appGroupAvailable: AppGroup.isAvailable,
|
appGroupAvailable: AppGroup.isAvailable,
|
||||||
hostReady: hostReady,
|
hostReady: hostReady,
|
||||||
isPreparingSession: isPendingFlowStart || hostWarming,
|
isPreparingSession: isPendingFlowStart || hostWarming,
|
||||||
hasCompletedOnboarding: state.hasCompletedOnboarding
|
hasCompletedOnboarding: voiceSetupReady
|
||||||
)
|
)
|
||||||
let signature = [
|
let signature = [
|
||||||
"phase=\(String(describing: state.phase))",
|
"phase=\(String(describing: state.phase))",
|
||||||
@@ -1070,7 +1074,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func beginFlowStart(recordAfterHandoff: Bool = false) {
|
func beginFlowStart(recordAfterHandoff: Bool = false) {
|
||||||
guard state.hasCompletedOnboarding else {
|
guard voiceSetupReady else {
|
||||||
promptFinishSetupInApp()
|
promptFinishSetupInApp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1177,6 +1181,8 @@ final class KeyboardFlowCoordinator {
|
|||||||
let mode: FlowUtteranceMode? = request.mode == .dictation
|
let mode: FlowUtteranceMode? = request.mode == .dictation
|
||||||
? nil
|
? nil
|
||||||
: request.mode
|
: request.mode
|
||||||
|
let managedRequestPurpose = request.managedRequestPurpose
|
||||||
|
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
|
||||||
let command = FlowCommand(
|
let command = FlowCommand(
|
||||||
sessionId: activeSessionId,
|
sessionId: activeSessionId,
|
||||||
utteranceId: currentUtteranceId,
|
utteranceId: currentUtteranceId,
|
||||||
@@ -1194,6 +1200,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
||||||
aiConversationID: request.aiConversationID,
|
aiConversationID: request.aiConversationID,
|
||||||
aiTaskKind: request.aiTaskKind,
|
aiTaskKind: request.aiTaskKind,
|
||||||
|
managedRequestPurpose: managedRequestPurpose,
|
||||||
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
||||||
processingDeadlineAt: action == .stopRecording && request.isEdit
|
processingDeadlineAt: action == .stopRecording && request.isEdit
|
||||||
? Date().timeIntervalSince1970
|
? Date().timeIntervalSince1970
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ final class KeyboardTextInserter {
|
|||||||
)
|
)
|
||||||
let inserted = separator + trimmed
|
let inserted = separator + trimmed
|
||||||
insertText(inserted)
|
insertText(inserted)
|
||||||
|
KeyboardSetupBridge.markVoiceInsertion()
|
||||||
state.noteUserDidInputText()
|
state.noteUserDidInputText()
|
||||||
recordLastInsertion(
|
recordLastInsertion(
|
||||||
inserted,
|
inserted,
|
||||||
|
|||||||
@@ -292,7 +292,15 @@ struct AIKeyboardView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var contextArea: some View {
|
private var contextArea: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let tip = state.skillTipText, !tip.isEmpty {
|
if state.isOnboardingPracticeActive, activeStatus == nil {
|
||||||
|
Text(ExtL10n.string("keyboard.onboarding.practice.mic"))
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: Layout.hotwordHeight)
|
||||||
|
.background(palette.accentMuted, in: Capsule())
|
||||||
|
} else if let tip = state.skillTipText, !tip.isEmpty {
|
||||||
IntrinsicWidthCap(maxWidth: Layout.skillTipMaxWidth) {
|
IntrinsicWidthCap(maxWidth: Layout.skillTipMaxWidth) {
|
||||||
Text(tip)
|
Text(tip)
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"onboarding.api.subtitle" = "Local ASR needs no key; add an API key to enable AI polish.";
|
"onboarding.api.subtitle" = "Local ASR needs no key; add an API key to enable AI polish.";
|
||||||
"onboarding.api.localReady.title" = "Local ASR is ready";
|
"onboarding.api.localReady.title" = "Local ASR is ready";
|
||||||
"onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish.";
|
"onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish.";
|
||||||
|
"keyboard.onboarding.practice.mic" = "Tap the mic, then tap again when done";
|
||||||
|
|
||||||
/* Common navigation */
|
/* Common navigation */
|
||||||
"common.back" = "Back";
|
"common.back" = "Back";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"onboarding.api.subtitle" = "本地识别无需 Key;填写 API Key 后可开启 AI 润色。";
|
"onboarding.api.subtitle" = "本地识别无需 Key;填写 API Key 后可开启 AI 润色。";
|
||||||
"onboarding.api.localReady.title" = "本地识别已就绪";
|
"onboarding.api.localReady.title" = "本地识别已就绪";
|
||||||
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
|
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
|
||||||
|
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
|
||||||
|
|
||||||
/* Common navigation */
|
/* Common navigation */
|
||||||
"common.back" = "返回";
|
"common.back" = "返回";
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// AnalyticsExtensionPrivacyTests.swift
|
||||||
|
// OSGKeyboardExtTests
|
||||||
|
//
|
||||||
|
// The keyboard analytics runtime is anonymous and emits only typed dimensions.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class AnalyticsExtensionPrivacyTests: XCTestCase {
|
||||||
|
func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws {
|
||||||
|
let databaseURL = try temporaryDatabaseURL()
|
||||||
|
let network = ExtensionAnalyticsNetwork()
|
||||||
|
let runtime = AnalyticsRuntime.keyboardExtension(
|
||||||
|
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"),
|
||||||
|
repositoryConfiguration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: databaseURL
|
||||||
|
),
|
||||||
|
uploadConfiguration: AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/v1/events")!
|
||||||
|
),
|
||||||
|
network: network,
|
||||||
|
wallClock: ExtensionAnalyticsClock(),
|
||||||
|
monotonicClock: ExtensionAnalyticsMonotonicClock(),
|
||||||
|
uuidGenerator: ExtensionAnalyticsUUIDGenerator(),
|
||||||
|
random: ExtensionAnalyticsRandomGenerator()
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime.client.recordKeyboardActivated()
|
||||||
|
for _ in 0..<200 {
|
||||||
|
if await runtime.repository.debugSnapshot().pendingEvents.count == 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
let pendingSnapshot = await runtime.repository.debugSnapshot()
|
||||||
|
XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1)
|
||||||
|
|
||||||
|
await runtime.uploadCoordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let requests = await network.requests()
|
||||||
|
let request = try XCTUnwrap(requests.first)
|
||||||
|
XCTAssertEqual(requests.count, 1)
|
||||||
|
XCTAssertNil(
|
||||||
|
request.headers.first {
|
||||||
|
$0.key.caseInsensitiveCompare("Authorization") == .orderedSame
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let upload = try JSONDecoder().decode(
|
||||||
|
AnalyticsUploadRequest.self,
|
||||||
|
from: request.body
|
||||||
|
)
|
||||||
|
XCTAssertEqual(upload.events.count, 1)
|
||||||
|
XCTAssertEqual(upload.events.first?.surface, .keyboard)
|
||||||
|
XCTAssertEqual(upload.events.first?.eventType, .keyboardActivated)
|
||||||
|
|
||||||
|
let root = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||||
|
)
|
||||||
|
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
|
||||||
|
let event = try XCTUnwrap(eventObjects.first)
|
||||||
|
XCTAssertFalse(event.keys.contains("properties"))
|
||||||
|
XCTAssertFalse(event.keys.contains("text"))
|
||||||
|
XCTAssertFalse(event.keys.contains("prompt"))
|
||||||
|
XCTAssertFalse(event.keys.contains("transcript"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func temporaryDatabaseURL() throws -> URL {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("OSGKeyboardExtensionAnalyticsTests", isDirectory: true)
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
return directory.appendingPathComponent("analytics.sqlite3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionAnalyticsClock: AnalyticsWallClock {
|
||||||
|
func now() -> Date {
|
||||||
|
Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionAnalyticsMonotonicClock: AnalyticsMonotonicClock {
|
||||||
|
func nowNanoseconds() -> UInt64 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ExtensionAnalyticsUUIDGenerator:
|
||||||
|
AnalyticsUUIDGenerating,
|
||||||
|
@unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value = 1
|
||||||
|
|
||||||
|
func makeUUID() -> UUID {
|
||||||
|
lock.withLock {
|
||||||
|
defer { value += 1 }
|
||||||
|
return UUID(
|
||||||
|
uuidString: String(
|
||||||
|
format: "00000000-0000-0000-0000-%012x",
|
||||||
|
value
|
||||||
|
)
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionAnalyticsRandomGenerator: AnalyticsRandomGenerating {
|
||||||
|
func next(upperBound: UInt64) -> UInt64 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor ExtensionAnalyticsNetwork: AnalyticsNetworking {
|
||||||
|
private var captured: [AnalyticsHTTPRequest] = []
|
||||||
|
|
||||||
|
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
|
||||||
|
captured.append(request)
|
||||||
|
let upload = try JSONDecoder().decode(
|
||||||
|
AnalyticsUploadRequest.self,
|
||||||
|
from: request.body
|
||||||
|
)
|
||||||
|
return AnalyticsHTTPResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
headers: [:],
|
||||||
|
body: Data(
|
||||||
|
#"{"accepted":\#(upload.events.count),"replayed":0}"#.utf8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requests() -> [AnalyticsHTTPRequest] {
|
||||||
|
captured
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,6 +209,27 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
|
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testManagedClientSerializesOOBERequestPurpose() async throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 3_750)
|
||||||
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
||||||
|
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
|
||||||
|
let client = ManagedLLMClient(
|
||||||
|
capability: .polish,
|
||||||
|
taskKind: .dictationPolish,
|
||||||
|
requestPurpose: .oobe,
|
||||||
|
grants: makeCoordinator(store: store, now: { now }),
|
||||||
|
baseURL: baseURL,
|
||||||
|
session: stubSession()
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.polish("input", systemPrompt: "context")
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(GatewayStub.shared.requests().last)
|
||||||
|
let body = try jsonBody(request)
|
||||||
|
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
|
||||||
|
XCTAssertEqual(body["requestPurpose"] as? String, "oobe")
|
||||||
|
}
|
||||||
|
|
||||||
func testManagedTaskKindWireValuesMatchServerContract() {
|
func testManagedTaskKindWireValuesMatchServerContract() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
ManagedGatewayTaskKind.allCases.map(\.rawValue),
|
ManagedGatewayTaskKind.allCases.map(\.rawValue),
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ public protocol ConfigurationStore: Sendable {
|
|||||||
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
|
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
|
||||||
var cloudASRPersistence: UserDefaults { get }
|
var cloudASRPersistence: UserDefaults { get }
|
||||||
|
|
||||||
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient
|
func makeClient(
|
||||||
|
taskKind: ManagedGatewayTaskKind?,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
) -> LLMClient
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension ConfigurationStore {
|
public extension ConfigurationStore {
|
||||||
@@ -48,6 +51,10 @@ public extension ConfigurationStore {
|
|||||||
var credentialSource: CredentialSource { .byok }
|
var credentialSource: CredentialSource { .byok }
|
||||||
|
|
||||||
func makeClient() -> LLMClient {
|
func makeClient() -> LLMClient {
|
||||||
makeClient(taskKind: nil)
|
makeClient(taskKind: nil, requestPurpose: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
||||||
|
makeClient(taskKind: taskKind, requestPurpose: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,15 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
|||||||
public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext }
|
public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext }
|
||||||
public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence }
|
public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence }
|
||||||
|
|
||||||
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
public func makeClient(
|
||||||
|
taskKind: ManagedGatewayTaskKind?,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
) -> LLMClient {
|
||||||
if credentialSource == .managed {
|
if credentialSource == .managed {
|
||||||
return ManagedLLMClient(
|
return ManagedLLMClient(
|
||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
grants: GatewayGrantCoordinator()
|
grants: GatewayGrantCoordinator()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// AnalyticsClient.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Synchronous, type-safe fire-and-forget API. Every asynchronous task owns only
|
||||||
|
// Sendable dependencies and analytics failures never escape into feature code.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol AnalyticsClient: Sendable {
|
||||||
|
func recordSessionActivity()
|
||||||
|
func recordKeyboardActivated()
|
||||||
|
func recordPurchaseViewed()
|
||||||
|
func recordPurchaseStarted()
|
||||||
|
func recordPurchaseCancelled()
|
||||||
|
func recordReferralShared()
|
||||||
|
func recordInviteOpened(
|
||||||
|
acquisitionChannel: AnalyticsAcquisitionChannel,
|
||||||
|
surface: AnalyticsSurface
|
||||||
|
)
|
||||||
|
func startAIFeature(
|
||||||
|
_ feature: AnalyticsFeature,
|
||||||
|
executionMode: AnalyticsExecutionMode
|
||||||
|
) -> any AnalyticsAIOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension AnalyticsClient {
|
||||||
|
func recordInviteOpened(
|
||||||
|
acquisitionChannel: AnalyticsAcquisitionChannel = .referral
|
||||||
|
) {
|
||||||
|
recordInviteOpened(
|
||||||
|
acquisitionChannel: acquisitionChannel,
|
||||||
|
surface: .inviteWeb
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsAIOperation: Sendable {
|
||||||
|
func succeed()
|
||||||
|
func fail(category: AnalyticsFailureCategory)
|
||||||
|
func cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class LiveAnalyticsClient: AnalyticsClient, Sendable {
|
||||||
|
private let repository: AnalyticsRepository
|
||||||
|
private let context: AnalyticsBootstrapContext
|
||||||
|
private let monotonicClock: any AnalyticsMonotonicClock
|
||||||
|
private let trigger: any AnalyticsUploadTriggering
|
||||||
|
|
||||||
|
public init(
|
||||||
|
repository: AnalyticsRepository,
|
||||||
|
context: AnalyticsBootstrapContext,
|
||||||
|
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
|
||||||
|
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger()
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.context = context
|
||||||
|
self.monotonicClock = monotonicClock
|
||||||
|
self.trigger = trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordSessionActivity() {
|
||||||
|
Task {
|
||||||
|
await repository.recordSessionIfNeeded(context: context)
|
||||||
|
await trigger.requestUpload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordKeyboardActivated() {
|
||||||
|
enqueue(eventType: .keyboardActivated)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordPurchaseViewed() {
|
||||||
|
enqueue(eventType: .purchaseViewed)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordPurchaseStarted() {
|
||||||
|
enqueue(eventType: .purchaseStarted)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordPurchaseCancelled() {
|
||||||
|
enqueue(
|
||||||
|
eventType: .purchaseCancelled,
|
||||||
|
dimensions: AnalyticsEventDimensions(failureCategory: .cancelled)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordReferralShared() {
|
||||||
|
enqueue(eventType: .referralShared)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordInviteOpened(
|
||||||
|
acquisitionChannel: AnalyticsAcquisitionChannel,
|
||||||
|
surface: AnalyticsSurface
|
||||||
|
) {
|
||||||
|
enqueue(
|
||||||
|
eventType: .inviteOpened,
|
||||||
|
surfaceOverride: surface,
|
||||||
|
dimensions: AnalyticsEventDimensions(
|
||||||
|
acquisitionChannel: acquisitionChannel
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func startAIFeature(
|
||||||
|
_ feature: AnalyticsFeature,
|
||||||
|
executionMode: AnalyticsExecutionMode
|
||||||
|
) -> any AnalyticsAIOperation {
|
||||||
|
let startNanoseconds = monotonicClock.nowNanoseconds()
|
||||||
|
let dimensions = AnalyticsEventDimensions(
|
||||||
|
feature: feature,
|
||||||
|
executionMode: executionMode
|
||||||
|
)
|
||||||
|
let startedTask = enqueue(
|
||||||
|
eventType: .aiFeatureStarted,
|
||||||
|
dimensions: dimensions
|
||||||
|
)
|
||||||
|
return LiveAnalyticsAIOperation(
|
||||||
|
repository: repository,
|
||||||
|
context: context,
|
||||||
|
feature: feature,
|
||||||
|
executionMode: executionMode,
|
||||||
|
startNanoseconds: startNanoseconds,
|
||||||
|
startedTask: startedTask,
|
||||||
|
monotonicClock: monotonicClock,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func enqueue(
|
||||||
|
eventType: AnalyticsEventType,
|
||||||
|
surfaceOverride: AnalyticsSurface? = nil,
|
||||||
|
dimensions: AnalyticsEventDimensions = .none
|
||||||
|
) -> Task<Void, Never> {
|
||||||
|
let recordTask = Task {
|
||||||
|
await repository.record(
|
||||||
|
eventType: eventType,
|
||||||
|
context: context,
|
||||||
|
surfaceOverride: surfaceOverride,
|
||||||
|
dimensions: dimensions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await recordTask.value
|
||||||
|
await trigger.requestUpload()
|
||||||
|
}
|
||||||
|
return recordTask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class LiveAnalyticsAIOperation: AnalyticsAIOperation, @unchecked Sendable {
|
||||||
|
private let repository: AnalyticsRepository
|
||||||
|
private let context: AnalyticsBootstrapContext
|
||||||
|
private let feature: AnalyticsFeature
|
||||||
|
private let executionMode: AnalyticsExecutionMode
|
||||||
|
private let startNanoseconds: UInt64
|
||||||
|
private let startedTask: Task<Void, Never>
|
||||||
|
private let monotonicClock: any AnalyticsMonotonicClock
|
||||||
|
private let trigger: any AnalyticsUploadTriggering
|
||||||
|
private let terminalLock = NSLock()
|
||||||
|
private var reachedTerminalState = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: AnalyticsRepository,
|
||||||
|
context: AnalyticsBootstrapContext,
|
||||||
|
feature: AnalyticsFeature,
|
||||||
|
executionMode: AnalyticsExecutionMode,
|
||||||
|
startNanoseconds: UInt64,
|
||||||
|
startedTask: Task<Void, Never>,
|
||||||
|
monotonicClock: any AnalyticsMonotonicClock,
|
||||||
|
trigger: any AnalyticsUploadTriggering
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.context = context
|
||||||
|
self.feature = feature
|
||||||
|
self.executionMode = executionMode
|
||||||
|
self.startNanoseconds = startNanoseconds
|
||||||
|
self.startedTask = startedTask
|
||||||
|
self.monotonicClock = monotonicClock
|
||||||
|
self.trigger = trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
public func succeed() {
|
||||||
|
finish(eventType: .aiFeatureSucceeded, failureCategory: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fail(category: AnalyticsFailureCategory) {
|
||||||
|
finish(eventType: .aiFeatureFailed, failureCategory: category)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
fail(category: .cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(
|
||||||
|
eventType: AnalyticsEventType,
|
||||||
|
failureCategory: AnalyticsFailureCategory?
|
||||||
|
) {
|
||||||
|
terminalLock.lock()
|
||||||
|
guard !reachedTerminalState else {
|
||||||
|
terminalLock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reachedTerminalState = true
|
||||||
|
terminalLock.unlock()
|
||||||
|
|
||||||
|
let endNanoseconds = monotonicClock.nowNanoseconds()
|
||||||
|
let elapsed = endNanoseconds >= startNanoseconds
|
||||||
|
? endNanoseconds - startNanoseconds
|
||||||
|
: 0
|
||||||
|
let dimensions = AnalyticsEventDimensions(
|
||||||
|
feature: feature,
|
||||||
|
executionMode: executionMode,
|
||||||
|
failureCategory: failureCategory,
|
||||||
|
durationBucket: AnalyticsDurationBucket(elapsedNanoseconds: elapsed)
|
||||||
|
)
|
||||||
|
Task {
|
||||||
|
// This explicit dependency guarantees STARTED reaches the repository
|
||||||
|
// before any terminal event, even when completion is immediate.
|
||||||
|
await startedTask.value
|
||||||
|
await repository.record(
|
||||||
|
eventType: eventType,
|
||||||
|
context: context,
|
||||||
|
dimensions: dimensions
|
||||||
|
)
|
||||||
|
await trigger.requestUpload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NoopAnalyticsAIOperation: AnalyticsAIOperation {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func succeed() {}
|
||||||
|
public func fail(category: AnalyticsFailureCategory) {}
|
||||||
|
public func cancel() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NoopAnalyticsClient: AnalyticsClient {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func recordSessionActivity() {}
|
||||||
|
public func recordKeyboardActivated() {}
|
||||||
|
public func recordPurchaseViewed() {}
|
||||||
|
public func recordPurchaseStarted() {}
|
||||||
|
public func recordPurchaseCancelled() {}
|
||||||
|
public func recordReferralShared() {}
|
||||||
|
|
||||||
|
public func recordInviteOpened(
|
||||||
|
acquisitionChannel: AnalyticsAcquisitionChannel,
|
||||||
|
surface: AnalyticsSurface
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public func startAIFeature(
|
||||||
|
_ feature: AnalyticsFeature,
|
||||||
|
executionMode: AnalyticsExecutionMode
|
||||||
|
) -> any AnalyticsAIOperation {
|
||||||
|
NoopAnalyticsAIOperation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias AnalyticsAISpan = any AnalyticsAIOperation
|
||||||
|
|
||||||
|
public struct AnalyticsRuntime: Sendable {
|
||||||
|
public let repository: AnalyticsRepository
|
||||||
|
public let client: any AnalyticsClient
|
||||||
|
public let uploadCoordinator: AnalyticsUploadCoordinator
|
||||||
|
public let context: AnalyticsBootstrapContext
|
||||||
|
|
||||||
|
private init(
|
||||||
|
surface: AnalyticsSurface,
|
||||||
|
environment: AnalyticsEnvironment,
|
||||||
|
repositoryConfiguration: AnalyticsRepositoryConfiguration,
|
||||||
|
uploadConfiguration: AnalyticsUploadConfiguration,
|
||||||
|
network: any AnalyticsNetworking,
|
||||||
|
bearerProvider: (any AnalyticsBearerProviding)?,
|
||||||
|
wallClock: any AnalyticsWallClock,
|
||||||
|
monotonicClock: any AnalyticsMonotonicClock,
|
||||||
|
uuidGenerator: any AnalyticsUUIDGenerating,
|
||||||
|
random: any AnalyticsRandomGenerating,
|
||||||
|
trigger: any AnalyticsUploadTriggering,
|
||||||
|
logger: any AnalyticsLogging
|
||||||
|
) {
|
||||||
|
let context = AnalyticsBootstrapContext(
|
||||||
|
surface: surface,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: repositoryConfiguration,
|
||||||
|
clock: wallClock,
|
||||||
|
uuidGenerator: uuidGenerator
|
||||||
|
)
|
||||||
|
self.context = context
|
||||||
|
self.repository = repository
|
||||||
|
client = LiveAnalyticsClient(
|
||||||
|
repository: repository,
|
||||||
|
context: context,
|
||||||
|
monotonicClock: monotonicClock,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
uploadCoordinator = AnalyticsUploadCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
configuration: uploadConfiguration,
|
||||||
|
network: network,
|
||||||
|
bearerProvider: bearerProvider,
|
||||||
|
clock: wallClock,
|
||||||
|
uuidGenerator: uuidGenerator,
|
||||||
|
random: random,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main-app runtime. A bearer provider may be supplied by host-only account
|
||||||
|
/// code without making the shared framework depend on that code.
|
||||||
|
public static func mainApp(
|
||||||
|
environment: AnalyticsEnvironment,
|
||||||
|
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
|
||||||
|
uploadConfiguration: AnalyticsUploadConfiguration,
|
||||||
|
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
|
||||||
|
bearerProvider: (any AnalyticsBearerProviding)? = nil,
|
||||||
|
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
||||||
|
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
|
||||||
|
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
|
||||||
|
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
|
||||||
|
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
|
||||||
|
logger: any AnalyticsLogging = NoopAnalyticsLogger()
|
||||||
|
) -> Self {
|
||||||
|
Self(
|
||||||
|
surface: .app,
|
||||||
|
environment: environment,
|
||||||
|
repositoryConfiguration: repositoryConfiguration,
|
||||||
|
uploadConfiguration: uploadConfiguration,
|
||||||
|
network: network,
|
||||||
|
bearerProvider: bearerProvider,
|
||||||
|
wallClock: wallClock,
|
||||||
|
monotonicClock: monotonicClock,
|
||||||
|
uuidGenerator: uuidGenerator,
|
||||||
|
random: random,
|
||||||
|
trigger: trigger,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard-extension runtime. This factory intentionally has no bearer
|
||||||
|
/// parameter, so extension uploads are anonymous by construction.
|
||||||
|
public static func keyboardExtension(
|
||||||
|
environment: AnalyticsEnvironment,
|
||||||
|
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
|
||||||
|
uploadConfiguration: AnalyticsUploadConfiguration,
|
||||||
|
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
|
||||||
|
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
||||||
|
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
|
||||||
|
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
|
||||||
|
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
|
||||||
|
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
|
||||||
|
logger: any AnalyticsLogging = NoopAnalyticsLogger()
|
||||||
|
) -> Self {
|
||||||
|
Self(
|
||||||
|
surface: .keyboard,
|
||||||
|
environment: environment,
|
||||||
|
repositoryConfiguration: repositoryConfiguration,
|
||||||
|
uploadConfiguration: uploadConfiguration,
|
||||||
|
network: network,
|
||||||
|
bearerProvider: nil,
|
||||||
|
wallClock: wallClock,
|
||||||
|
monotonicClock: monotonicClock,
|
||||||
|
uuidGenerator: uuidGenerator,
|
||||||
|
random: random,
|
||||||
|
trigger: trigger,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setEnabled(_ enabled: Bool) async {
|
||||||
|
await repository.setEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-only explicit initialization point. Call after processing the
|
||||||
|
/// cold-start URL so FIRST_OPEN receives the final acquisition channel.
|
||||||
|
public func prepare(
|
||||||
|
firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown
|
||||||
|
) async {
|
||||||
|
await repository.prepare(
|
||||||
|
using: context,
|
||||||
|
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isEnabled() async -> Bool {
|
||||||
|
await repository.isEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func observeAccount(
|
||||||
|
stableIdentifier: String
|
||||||
|
) async -> AnalyticsAccountObservation {
|
||||||
|
await repository.observeAccount(
|
||||||
|
stableIdentifier: stableIdentifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handleAccountDeletion() async {
|
||||||
|
await repository.handleAccountDeletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// AnalyticsDependencies.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Injectable system boundaries used by both the host app and keyboard extension.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol AnalyticsWallClock: Sendable {
|
||||||
|
func now() -> Date
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsMonotonicClock: Sendable {
|
||||||
|
func nowNanoseconds() -> UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsUUIDGenerating: Sendable {
|
||||||
|
func makeUUID() -> UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsRandomGenerating: Sendable {
|
||||||
|
/// Returns a value in the closed range 0...upperBound.
|
||||||
|
func next(upperBound: UInt64) -> UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemAnalyticsWallClock: AnalyticsWallClock {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func now() -> Date {
|
||||||
|
Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemAnalyticsMonotonicClock: AnalyticsMonotonicClock {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func nowNanoseconds() -> UInt64 {
|
||||||
|
DispatchTime.now().uptimeNanoseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemAnalyticsUUIDGenerator: AnalyticsUUIDGenerating {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func makeUUID() -> UUID {
|
||||||
|
UUID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class SystemAnalyticsRandomGenerator: AnalyticsRandomGenerating, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var generator = SystemRandomNumberGenerator()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func next(upperBound: UInt64) -> UInt64 {
|
||||||
|
guard upperBound > 0 else { return 0 }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return UInt64.random(in: 0...upperBound, using: &generator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsHTTPRequest: Sendable {
|
||||||
|
public let url: URL
|
||||||
|
public let headers: [String: String]
|
||||||
|
public let body: Data
|
||||||
|
|
||||||
|
public init(url: URL, headers: [String: String], body: Data) {
|
||||||
|
self.url = url
|
||||||
|
self.headers = headers
|
||||||
|
self.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsHTTPResponse: Sendable {
|
||||||
|
public let statusCode: Int
|
||||||
|
public let headers: [String: String]
|
||||||
|
public let body: Data
|
||||||
|
|
||||||
|
public init(statusCode: Int, headers: [String: String], body: Data) {
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.headers = headers
|
||||||
|
self.body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
func header(named name: String) -> String? {
|
||||||
|
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsNetworking: Sendable {
|
||||||
|
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsBearerProviding: Sendable {
|
||||||
|
func bearerToken() async throws -> String?
|
||||||
|
func refreshBearerToken(
|
||||||
|
afterUnauthorizedAccessToken failedToken: String?
|
||||||
|
) async throws -> String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsUploadTriggering: Sendable {
|
||||||
|
func requestUpload() async
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NoopAnalyticsUploadTrigger: AnalyticsUploadTriggering {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func requestUpload() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsUploadErrorCategory: String, Sendable {
|
||||||
|
case network
|
||||||
|
case timeout
|
||||||
|
case authentication
|
||||||
|
case rateLimited
|
||||||
|
case server
|
||||||
|
case client
|
||||||
|
case decoding
|
||||||
|
case countMismatch
|
||||||
|
case storage
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsUploadLogEntry: Sendable {
|
||||||
|
public enum Outcome: String, Sendable {
|
||||||
|
case uploaded
|
||||||
|
case retryScheduled
|
||||||
|
case quarantined
|
||||||
|
case skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
public let outcome: Outcome
|
||||||
|
public let eventCount: Int
|
||||||
|
public let statusCode: Int?
|
||||||
|
public let attempt: Int
|
||||||
|
public let errorCategory: AnalyticsUploadErrorCategory?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
outcome: Outcome,
|
||||||
|
eventCount: Int,
|
||||||
|
statusCode: Int? = nil,
|
||||||
|
attempt: Int = 0,
|
||||||
|
errorCategory: AnalyticsUploadErrorCategory? = nil
|
||||||
|
) {
|
||||||
|
self.outcome = outcome
|
||||||
|
self.eventCount = eventCount
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.attempt = attempt
|
||||||
|
self.errorCategory = errorCategory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AnalyticsLogging: Sendable {
|
||||||
|
func log(_ entry: AnalyticsUploadLogEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NoopAnalyticsLogger: AnalyticsLogging {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func log(_ entry: AnalyticsUploadLogEntry) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsRepositoryConfiguration: Sendable {
|
||||||
|
public static let defaultDatabaseFilename = "analytics.sqlite3"
|
||||||
|
|
||||||
|
public let databaseURL: URL?
|
||||||
|
public let maximumEventCount: Int
|
||||||
|
public let maximumStoredBytes: Int
|
||||||
|
public let eventRetention: TimeInterval
|
||||||
|
public let busyTimeoutMilliseconds: Int32
|
||||||
|
|
||||||
|
public init(
|
||||||
|
databaseURL: URL?,
|
||||||
|
maximumEventCount: Int = 10_000,
|
||||||
|
maximumStoredBytes: Int = 5 * 1_024 * 1_024,
|
||||||
|
eventRetention: TimeInterval = 34 * 24 * 60 * 60,
|
||||||
|
busyTimeoutMilliseconds: Int32 = 2_000
|
||||||
|
) {
|
||||||
|
self.databaseURL = databaseURL
|
||||||
|
self.maximumEventCount = max(1, maximumEventCount)
|
||||||
|
self.maximumStoredBytes = max(1_024, maximumStoredBytes)
|
||||||
|
self.eventRetention = max(60, eventRetention)
|
||||||
|
self.busyTimeoutMilliseconds = max(0, busyTimeoutMilliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func appGroupDefault(
|
||||||
|
appGroupIdentifier: String = AppGroup.identifier
|
||||||
|
) -> Self {
|
||||||
|
let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
||||||
|
)
|
||||||
|
let directory = container?.appendingPathComponent(
|
||||||
|
"Library/Application Support/Analytics",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
return Self(
|
||||||
|
databaseURL: directory?.appendingPathComponent(defaultDatabaseFilename)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsUploadConfiguration: Sendable {
|
||||||
|
public let endpoint: URL
|
||||||
|
public let maximumBatchCount: Int
|
||||||
|
public let maximumBodyBytes: Int
|
||||||
|
public let globalLeaseDuration: TimeInterval
|
||||||
|
public let eventLeaseDuration: TimeInterval
|
||||||
|
public let maximumBackoff: TimeInterval
|
||||||
|
|
||||||
|
public init(
|
||||||
|
endpoint: URL,
|
||||||
|
maximumBatchCount: Int = 50,
|
||||||
|
maximumBodyBytes: Int = 60 * 1_024,
|
||||||
|
globalLeaseDuration: TimeInterval = 2 * 60,
|
||||||
|
eventLeaseDuration: TimeInterval = 5 * 60,
|
||||||
|
maximumBackoff: TimeInterval = 6 * 60 * 60
|
||||||
|
) {
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.maximumBatchCount = min(50, max(1, maximumBatchCount))
|
||||||
|
self.maximumBodyBytes = min(60 * 1_024, max(1_024, maximumBodyBytes))
|
||||||
|
// Both leases outlive the default 30-second transport timeout. The
|
||||||
|
// coordinator also renews them immediately before every request.
|
||||||
|
self.globalLeaseDuration = max(60, globalLeaseDuration)
|
||||||
|
self.eventLeaseDuration = max(60, eventLeaseDuration)
|
||||||
|
self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
// AnalyticsModels.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Privacy-preserving analytics wire contract. There is intentionally no
|
||||||
|
// free-form properties dictionary or user-provided text in this model.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AnalyticsEventType: String, Codable, CaseIterable, Sendable {
|
||||||
|
case firstOpen = "FIRST_OPEN"
|
||||||
|
case sessionStarted = "SESSION_STARTED"
|
||||||
|
case keyboardActivated = "KEYBOARD_ACTIVATED"
|
||||||
|
case aiFeatureStarted = "AI_FEATURE_STARTED"
|
||||||
|
case aiFeatureSucceeded = "AI_FEATURE_SUCCEEDED"
|
||||||
|
case aiFeatureFailed = "AI_FEATURE_FAILED"
|
||||||
|
case purchaseViewed = "PURCHASE_VIEWED"
|
||||||
|
case purchaseStarted = "PURCHASE_STARTED"
|
||||||
|
case purchaseCancelled = "PURCHASE_CANCELLED"
|
||||||
|
case referralShared = "REFERRAL_SHARED"
|
||||||
|
case inviteOpened = "INVITE_OPENED"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsSurface: String, Codable, CaseIterable, Sendable {
|
||||||
|
case app = "APP"
|
||||||
|
case keyboard = "KEYBOARD"
|
||||||
|
case inviteWeb = "INVITE_WEB"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsAcquisitionChannel: String, Codable, CaseIterable, Sendable {
|
||||||
|
case appStoreOrganic = "APP_STORE_ORGANIC"
|
||||||
|
case referral = "REFERRAL"
|
||||||
|
case socialContent = "SOCIAL_CONTENT"
|
||||||
|
case unknown = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsFeature: String, Codable, CaseIterable, Sendable {
|
||||||
|
case transcription = "TRANSCRIPTION"
|
||||||
|
case polish = "POLISH"
|
||||||
|
case aiAssistant = "AI_ASSISTANT"
|
||||||
|
case agent = "AGENT"
|
||||||
|
case hotword = "HOTWORD"
|
||||||
|
case other = "OTHER"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsExecutionMode: String, Codable, CaseIterable, Sendable {
|
||||||
|
case managed = "MANAGED"
|
||||||
|
case local = "LOCAL"
|
||||||
|
case byok = "BYOK"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsFailureCategory: String, Codable, CaseIterable, Sendable {
|
||||||
|
case network = "NETWORK"
|
||||||
|
case provider = "PROVIDER"
|
||||||
|
case timeout = "TIMEOUT"
|
||||||
|
case cancelled = "CANCELLED"
|
||||||
|
case insufficientCredits = "INSUFFICIENT_CREDITS"
|
||||||
|
case validation = "VALIDATION"
|
||||||
|
case unknown = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsDurationBucket: String, Codable, CaseIterable, Sendable {
|
||||||
|
case lessThanOneSecond = "LT_1S"
|
||||||
|
case oneToThreeSeconds = "S1_TO_3"
|
||||||
|
case threeToTenSeconds = "S3_TO_10"
|
||||||
|
case tenToThirtySeconds = "S10_TO_30"
|
||||||
|
case thirtySecondsOrMore = "GTE_30S"
|
||||||
|
|
||||||
|
public init(elapsedNanoseconds: UInt64) {
|
||||||
|
switch elapsedNanoseconds {
|
||||||
|
case ..<1_000_000_000:
|
||||||
|
self = .lessThanOneSecond
|
||||||
|
case ..<3_000_000_000:
|
||||||
|
self = .oneToThreeSeconds
|
||||||
|
case ..<10_000_000_000:
|
||||||
|
self = .threeToTenSeconds
|
||||||
|
case ..<30_000_000_000:
|
||||||
|
self = .tenToThirtySeconds
|
||||||
|
default:
|
||||||
|
self = .thirtySecondsOrMore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AnalyticsModelError: Error, Sendable {
|
||||||
|
case unknownField(String)
|
||||||
|
case invalidDimensions(AnalyticsEventType)
|
||||||
|
case invalidVersion
|
||||||
|
case invalidResponseCounts
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||||
|
public let installationId: UUID
|
||||||
|
public let clientEventId: UUID
|
||||||
|
public let eventType: AnalyticsEventType
|
||||||
|
public let occurredAt: Date
|
||||||
|
public let surface: AnalyticsSurface
|
||||||
|
public let appVersion: String
|
||||||
|
public let osVersion: String
|
||||||
|
public let acquisitionChannel: AnalyticsAcquisitionChannel?
|
||||||
|
public let feature: AnalyticsFeature?
|
||||||
|
public let executionMode: AnalyticsExecutionMode?
|
||||||
|
public let failureCategory: AnalyticsFailureCategory?
|
||||||
|
public let durationBucket: AnalyticsDurationBucket?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
installationId: UUID,
|
||||||
|
clientEventId: UUID,
|
||||||
|
eventType: AnalyticsEventType,
|
||||||
|
occurredAt: Date,
|
||||||
|
surface: AnalyticsSurface,
|
||||||
|
appVersion: String,
|
||||||
|
osVersion: String,
|
||||||
|
acquisitionChannel: AnalyticsAcquisitionChannel? = nil,
|
||||||
|
feature: AnalyticsFeature? = nil,
|
||||||
|
executionMode: AnalyticsExecutionMode? = nil,
|
||||||
|
failureCategory: AnalyticsFailureCategory? = nil,
|
||||||
|
durationBucket: AnalyticsDurationBucket? = nil
|
||||||
|
) throws {
|
||||||
|
guard AnalyticsEnvironment.isSafeVersion(appVersion),
|
||||||
|
AnalyticsEnvironment.isSafeVersion(osVersion) else {
|
||||||
|
throw AnalyticsModelError.invalidVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
self.installationId = installationId
|
||||||
|
self.clientEventId = clientEventId
|
||||||
|
self.eventType = eventType
|
||||||
|
self.occurredAt = occurredAt
|
||||||
|
self.surface = surface
|
||||||
|
self.appVersion = appVersion
|
||||||
|
self.osVersion = osVersion
|
||||||
|
self.acquisitionChannel = acquisitionChannel
|
||||||
|
self.feature = feature
|
||||||
|
self.executionMode = executionMode
|
||||||
|
self.failureCategory = failureCategory
|
||||||
|
self.durationBucket = durationBucket
|
||||||
|
|
||||||
|
guard dimensionsAreAllowed else {
|
||||||
|
throw AnalyticsModelError.invalidDimensions(eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||||
|
case installationId
|
||||||
|
case clientEventId
|
||||||
|
case eventType
|
||||||
|
case occurredAt
|
||||||
|
case surface
|
||||||
|
case appVersion
|
||||||
|
case osVersion
|
||||||
|
case acquisitionChannel
|
||||||
|
case feature
|
||||||
|
case executionMode
|
||||||
|
case failureCategory
|
||||||
|
case durationBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
try AnalyticsCodableAllowlist.rejectUnknownKeys(
|
||||||
|
in: decoder,
|
||||||
|
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||||
|
)
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let installationId = try container.decode(UUID.self, forKey: .installationId)
|
||||||
|
let clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
|
||||||
|
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
|
||||||
|
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
|
||||||
|
guard let occurredAt = AnalyticsWireDate.date(from: occurredAtText) else {
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
forKey: .occurredAt,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "occurredAt must be a UTC ISO-8601 timestamp"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try self.init(
|
||||||
|
installationId: installationId,
|
||||||
|
clientEventId: clientEventId,
|
||||||
|
eventType: eventType,
|
||||||
|
occurredAt: occurredAt,
|
||||||
|
surface: try container.decode(AnalyticsSurface.self, forKey: .surface),
|
||||||
|
appVersion: try container.decode(String.self, forKey: .appVersion),
|
||||||
|
osVersion: try container.decode(String.self, forKey: .osVersion),
|
||||||
|
acquisitionChannel: try container.decodeIfPresent(
|
||||||
|
AnalyticsAcquisitionChannel.self,
|
||||||
|
forKey: .acquisitionChannel
|
||||||
|
),
|
||||||
|
feature: try container.decodeIfPresent(AnalyticsFeature.self, forKey: .feature),
|
||||||
|
executionMode: try container.decodeIfPresent(
|
||||||
|
AnalyticsExecutionMode.self,
|
||||||
|
forKey: .executionMode
|
||||||
|
),
|
||||||
|
failureCategory: try container.decodeIfPresent(
|
||||||
|
AnalyticsFailureCategory.self,
|
||||||
|
forKey: .failureCategory
|
||||||
|
),
|
||||||
|
durationBucket: try container.decodeIfPresent(
|
||||||
|
AnalyticsDurationBucket.self,
|
||||||
|
forKey: .durationBucket
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
guard dimensionsAreAllowed else {
|
||||||
|
throw AnalyticsModelError.invalidDimensions(eventType)
|
||||||
|
}
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(installationId, forKey: .installationId)
|
||||||
|
try container.encode(clientEventId, forKey: .clientEventId)
|
||||||
|
try container.encode(eventType, forKey: .eventType)
|
||||||
|
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
|
||||||
|
try container.encode(surface, forKey: .surface)
|
||||||
|
try container.encode(appVersion, forKey: .appVersion)
|
||||||
|
try container.encode(osVersion, forKey: .osVersion)
|
||||||
|
try container.encodeIfPresent(acquisitionChannel, forKey: .acquisitionChannel)
|
||||||
|
try container.encodeIfPresent(feature, forKey: .feature)
|
||||||
|
try container.encodeIfPresent(executionMode, forKey: .executionMode)
|
||||||
|
try container.encodeIfPresent(failureCategory, forKey: .failureCategory)
|
||||||
|
try container.encodeIfPresent(durationBucket, forKey: .durationBucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dimensionsAreAllowed: Bool {
|
||||||
|
let present = DimensionSet(
|
||||||
|
acquisitionChannel: acquisitionChannel != nil,
|
||||||
|
feature: feature != nil,
|
||||||
|
executionMode: executionMode != nil,
|
||||||
|
failureCategory: failureCategory != nil,
|
||||||
|
durationBucket: durationBucket != nil
|
||||||
|
)
|
||||||
|
guard eventType.allowedDimensionSets.contains(present) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if eventType == .purchaseCancelled {
|
||||||
|
return failureCategory == .cancelled
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
|
||||||
|
public let events: [AnalyticsEvent]
|
||||||
|
|
||||||
|
public init(events: [AnalyticsEvent]) {
|
||||||
|
self.events = events
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||||
|
case events
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
try AnalyticsCodableAllowlist.rejectUnknownKeys(
|
||||||
|
in: decoder,
|
||||||
|
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||||
|
)
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
events = try container.decode([AnalyticsEvent].self, forKey: .events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsUploadResponse: Codable, Equatable, Sendable {
|
||||||
|
public let accepted: Int
|
||||||
|
public let replayed: Int
|
||||||
|
|
||||||
|
public init(accepted: Int, replayed: Int) throws {
|
||||||
|
guard accepted >= 0, replayed >= 0 else {
|
||||||
|
throw AnalyticsModelError.invalidResponseCounts
|
||||||
|
}
|
||||||
|
self.accepted = accepted
|
||||||
|
self.replayed = replayed
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||||
|
case accepted
|
||||||
|
case replayed
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
try AnalyticsCodableAllowlist.rejectUnknownKeys(
|
||||||
|
in: decoder,
|
||||||
|
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||||
|
)
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(
|
||||||
|
accepted: container.decode(Int.self, forKey: .accepted),
|
||||||
|
replayed: container.decode(Int.self, forKey: .replayed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AnalyticsEnvironment: Equatable, Sendable {
|
||||||
|
public let appVersion: String
|
||||||
|
public let osVersion: String
|
||||||
|
|
||||||
|
public init(appVersion: String, osVersion: String) {
|
||||||
|
self.appVersion = Self.sanitizedVersion(appVersion)
|
||||||
|
self.osVersion = Self.sanitizedVersion(osVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isSafeVersion(_ value: String) -> Bool {
|
||||||
|
!value.isEmpty
|
||||||
|
&& value.utf8.count <= 32
|
||||||
|
&& value.unicodeScalars.allSatisfy {
|
||||||
|
CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_")
|
||||||
|
.contains($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedVersion(_ value: String) -> String {
|
||||||
|
let allowed = CharacterSet(
|
||||||
|
charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_"
|
||||||
|
)
|
||||||
|
let filtered = String(value.unicodeScalars.filter(allowed.contains).prefix(32))
|
||||||
|
return filtered.isEmpty ? "unknown" : filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DimensionSet: Hashable {
|
||||||
|
let acquisitionChannel: Bool
|
||||||
|
let feature: Bool
|
||||||
|
let executionMode: Bool
|
||||||
|
let failureCategory: Bool
|
||||||
|
let durationBucket: Bool
|
||||||
|
|
||||||
|
static let none = Self(
|
||||||
|
acquisitionChannel: false,
|
||||||
|
feature: false,
|
||||||
|
executionMode: false,
|
||||||
|
failureCategory: false,
|
||||||
|
durationBucket: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AnalyticsEventType {
|
||||||
|
var allowedDimensionSets: Set<DimensionSet> {
|
||||||
|
switch self {
|
||||||
|
case .firstOpen, .inviteOpened:
|
||||||
|
return [
|
||||||
|
DimensionSet(
|
||||||
|
acquisitionChannel: true,
|
||||||
|
feature: false,
|
||||||
|
executionMode: false,
|
||||||
|
failureCategory: false,
|
||||||
|
durationBucket: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case .aiFeatureStarted:
|
||||||
|
return [
|
||||||
|
DimensionSet(
|
||||||
|
acquisitionChannel: false,
|
||||||
|
feature: true,
|
||||||
|
executionMode: true,
|
||||||
|
failureCategory: false,
|
||||||
|
durationBucket: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case .aiFeatureSucceeded:
|
||||||
|
return [
|
||||||
|
DimensionSet(
|
||||||
|
acquisitionChannel: false,
|
||||||
|
feature: true,
|
||||||
|
executionMode: true,
|
||||||
|
failureCategory: false,
|
||||||
|
durationBucket: true
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case .aiFeatureFailed:
|
||||||
|
return [
|
||||||
|
DimensionSet(
|
||||||
|
acquisitionChannel: false,
|
||||||
|
feature: true,
|
||||||
|
executionMode: true,
|
||||||
|
failureCategory: true,
|
||||||
|
durationBucket: true
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case .sessionStarted,
|
||||||
|
.keyboardActivated,
|
||||||
|
.purchaseViewed,
|
||||||
|
.purchaseStarted,
|
||||||
|
.referralShared:
|
||||||
|
return [.none]
|
||||||
|
case .purchaseCancelled:
|
||||||
|
return [
|
||||||
|
DimensionSet(
|
||||||
|
acquisitionChannel: false,
|
||||||
|
feature: false,
|
||||||
|
executionMode: false,
|
||||||
|
failureCategory: true,
|
||||||
|
durationBucket: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnalyticsCanonicalJSON {
|
||||||
|
static func encode<T: Encodable>(_ value: T) throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
||||||
|
return try encoder.encode(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnalyticsWireDate {
|
||||||
|
static func string(from date: Date) -> String {
|
||||||
|
formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func date(from value: String) -> Date? {
|
||||||
|
formatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var formatter: ISO8601DateFormatter {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AnalyticsCodableAllowlist {
|
||||||
|
static func rejectUnknownKeys(
|
||||||
|
in decoder: Decoder,
|
||||||
|
allowed: Set<String>
|
||||||
|
) throws {
|
||||||
|
let container = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
if let unknown = container.allKeys.first(where: { !allowed.contains($0.stringValue) }) {
|
||||||
|
throw AnalyticsModelError.unknownField(unknown.stringValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AnyCodingKey: CodingKey {
|
||||||
|
let stringValue: String
|
||||||
|
let intValue: Int?
|
||||||
|
|
||||||
|
init?(stringValue: String) {
|
||||||
|
self.stringValue = stringValue
|
||||||
|
intValue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(intValue: Int) {
|
||||||
|
stringValue = String(intValue)
|
||||||
|
self.intValue = intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
|||||||
|
// AnalyticsUploadCoordinator.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Bounded uploader with cross-process leases, retry policy and poison-event
|
||||||
|
// isolation. Logs never contain payloads, identifiers, endpoints or tokens.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, @unchecked Sendable {
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
public init(session: URLSession = .shared) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
public func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
|
||||||
|
var urlRequest = URLRequest(url: request.url)
|
||||||
|
urlRequest.httpMethod = "POST"
|
||||||
|
urlRequest.httpBody = request.body
|
||||||
|
urlRequest.timeoutInterval = 30
|
||||||
|
for (name, value) in request.headers {
|
||||||
|
urlRequest.setValue(value, forHTTPHeaderField: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (body, response) = try await session.data(for: urlRequest)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw AnalyticsNetworkError.nonHTTPResponse
|
||||||
|
}
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
for (key, value) in httpResponse.allHeaderFields {
|
||||||
|
headers[String(describing: key)] = String(describing: value)
|
||||||
|
}
|
||||||
|
return AnalyticsHTTPResponse(
|
||||||
|
statusCode: httpResponse.statusCode,
|
||||||
|
headers: headers,
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor AnalyticsUploadCoordinator {
|
||||||
|
private struct AuthorizationState {
|
||||||
|
var token: String?
|
||||||
|
var didRefresh = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private let repository: AnalyticsRepository
|
||||||
|
private let configuration: AnalyticsUploadConfiguration
|
||||||
|
private let network: any AnalyticsNetworking
|
||||||
|
private let bearerProvider: (any AnalyticsBearerProviding)?
|
||||||
|
private let clock: any AnalyticsWallClock
|
||||||
|
private let uuidGenerator: any AnalyticsUUIDGenerating
|
||||||
|
private let random: any AnalyticsRandomGenerating
|
||||||
|
private let logger: any AnalyticsLogging
|
||||||
|
private var uploadInProgress = false
|
||||||
|
|
||||||
|
public init(
|
||||||
|
repository: AnalyticsRepository,
|
||||||
|
configuration: AnalyticsUploadConfiguration,
|
||||||
|
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
|
||||||
|
bearerProvider: (any AnalyticsBearerProviding)? = nil,
|
||||||
|
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
||||||
|
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
|
||||||
|
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
|
||||||
|
logger: any AnalyticsLogging = NoopAnalyticsLogger()
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.configuration = configuration
|
||||||
|
self.network = network
|
||||||
|
self.bearerProvider = bearerProvider
|
||||||
|
self.clock = clock
|
||||||
|
self.uuidGenerator = uuidGenerator
|
||||||
|
self.random = random
|
||||||
|
self.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a bounded drain. The default keeps extension execution time and
|
||||||
|
/// memory predictable while allowing a host app to request more batches.
|
||||||
|
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
|
||||||
|
guard !uploadInProgress else { return }
|
||||||
|
uploadInProgress = true
|
||||||
|
defer { uploadInProgress = false }
|
||||||
|
|
||||||
|
guard configuration.endpoint.scheme?.lowercased() == "https" else {
|
||||||
|
logger.log(
|
||||||
|
AnalyticsUploadLogEntry(
|
||||||
|
outcome: .skipped,
|
||||||
|
eventCount: 0,
|
||||||
|
errorCategory: .client
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
|
||||||
|
let batchLimit = max(1, maximumBatches)
|
||||||
|
for _ in 0..<batchLimit {
|
||||||
|
guard let batch = await repository.leaseBatch(
|
||||||
|
ownerID: ownerID,
|
||||||
|
configuration: configuration
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorization = AuthorizationState()
|
||||||
|
if let bearerProvider {
|
||||||
|
do {
|
||||||
|
authorization.token = try await bearerProvider.bearerToken()
|
||||||
|
} catch {
|
||||||
|
await scheduleRetry(
|
||||||
|
events: batch.events,
|
||||||
|
leaseID: batch.leaseID,
|
||||||
|
response: nil,
|
||||||
|
category: .authentication
|
||||||
|
)
|
||||||
|
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await process(
|
||||||
|
events: batch.events,
|
||||||
|
leaseID: batch.leaseID,
|
||||||
|
ownerID: ownerID,
|
||||||
|
authorization: &authorization
|
||||||
|
)
|
||||||
|
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func process(
|
||||||
|
events: [AnalyticsLeasedEvent],
|
||||||
|
leaseID: String,
|
||||||
|
ownerID: String,
|
||||||
|
authorization: inout AuthorizationState
|
||||||
|
) async {
|
||||||
|
guard !events.isEmpty else { return }
|
||||||
|
guard await renewLease(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
ownerID: ownerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: AnalyticsHTTPResponse
|
||||||
|
do {
|
||||||
|
response = try await send(events: events, token: authorization.token)
|
||||||
|
} catch {
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: nil,
|
||||||
|
category: Self.networkCategory(for: error)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.statusCode == 401,
|
||||||
|
!authorization.didRefresh,
|
||||||
|
let bearerProvider {
|
||||||
|
authorization.didRefresh = true
|
||||||
|
let failedToken = authorization.token
|
||||||
|
do {
|
||||||
|
authorization.token = try await bearerProvider.refreshBearerToken(
|
||||||
|
afterUnauthorizedAccessToken: failedToken
|
||||||
|
)
|
||||||
|
guard await renewLease(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
ownerID: ownerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let refreshed = try await send(events: events, token: authorization.token)
|
||||||
|
await processResponse(
|
||||||
|
refreshed,
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
ownerID: ownerID,
|
||||||
|
authorization: &authorization
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .authentication,
|
||||||
|
minimumDelay: configuration.maximumBackoff
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await processResponse(
|
||||||
|
response,
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
ownerID: ownerID,
|
||||||
|
authorization: &authorization
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processResponse(
|
||||||
|
_ response: AnalyticsHTTPResponse,
|
||||||
|
events: [AnalyticsLeasedEvent],
|
||||||
|
leaseID: String,
|
||||||
|
ownerID: String,
|
||||||
|
authorization: inout AuthorizationState
|
||||||
|
) async {
|
||||||
|
switch response.statusCode {
|
||||||
|
case 200:
|
||||||
|
let decoded: AnalyticsUploadResponse
|
||||||
|
do {
|
||||||
|
decoded = try JSONDecoder().decode(
|
||||||
|
AnalyticsUploadResponse.self,
|
||||||
|
from: response.body
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .decoding
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard decoded.accepted + decoded.replayed == events.count else {
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .countMismatch
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let completed = await repository.complete(
|
||||||
|
rowIDs: events.map(\.rowID),
|
||||||
|
leaseID: leaseID
|
||||||
|
)
|
||||||
|
logger.log(
|
||||||
|
AnalyticsUploadLogEntry(
|
||||||
|
outcome: completed ? .uploaded : .retryScheduled,
|
||||||
|
eventCount: events.count,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
attempt: maximumAttempt(in: events),
|
||||||
|
errorCategory: completed ? nil : .storage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case 400, 409, 422:
|
||||||
|
if events.count == 1 {
|
||||||
|
await repository.quarantine(
|
||||||
|
rowIDs: [events[0].rowID],
|
||||||
|
leaseID: leaseID,
|
||||||
|
reason: "http\(response.statusCode)"
|
||||||
|
)
|
||||||
|
logger.log(
|
||||||
|
AnalyticsUploadLogEntry(
|
||||||
|
outcome: .quarantined,
|
||||||
|
eventCount: 1,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
attempt: events[0].attemptCount,
|
||||||
|
errorCategory: .client
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let midpoint = events.count / 2
|
||||||
|
await process(
|
||||||
|
events: Array(events[..<midpoint]),
|
||||||
|
leaseID: leaseID,
|
||||||
|
ownerID: ownerID,
|
||||||
|
authorization: &authorization
|
||||||
|
)
|
||||||
|
await process(
|
||||||
|
events: Array(events[midpoint...]),
|
||||||
|
leaseID: leaseID,
|
||||||
|
ownerID: ownerID,
|
||||||
|
authorization: &authorization
|
||||||
|
)
|
||||||
|
|
||||||
|
case 401:
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .authentication,
|
||||||
|
minimumDelay: configuration.maximumBackoff
|
||||||
|
)
|
||||||
|
|
||||||
|
case 408:
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
case 429:
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .rateLimited
|
||||||
|
)
|
||||||
|
|
||||||
|
case 500...599:
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .server
|
||||||
|
)
|
||||||
|
|
||||||
|
case 400...499:
|
||||||
|
await repository.quarantine(
|
||||||
|
rowIDs: events.map(\.rowID),
|
||||||
|
leaseID: leaseID,
|
||||||
|
reason: "http\(response.statusCode)"
|
||||||
|
)
|
||||||
|
logger.log(
|
||||||
|
AnalyticsUploadLogEntry(
|
||||||
|
outcome: .quarantined,
|
||||||
|
eventCount: events.count,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
attempt: maximumAttempt(in: events),
|
||||||
|
errorCategory: .client
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
await scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
response: response,
|
||||||
|
category: .server
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(
|
||||||
|
events: [AnalyticsLeasedEvent],
|
||||||
|
token: String?
|
||||||
|
) async throws -> AnalyticsHTTPResponse {
|
||||||
|
var headers = [
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
]
|
||||||
|
if let token, !token.isEmpty {
|
||||||
|
headers["Authorization"] = "Bearer \(token)"
|
||||||
|
}
|
||||||
|
return try await network.send(
|
||||||
|
AnalyticsHTTPRequest(
|
||||||
|
url: configuration.endpoint,
|
||||||
|
headers: headers,
|
||||||
|
body: Self.requestBody(for: events)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renewLease(
|
||||||
|
events: [AnalyticsLeasedEvent],
|
||||||
|
leaseID: String,
|
||||||
|
ownerID: String
|
||||||
|
) async -> Bool {
|
||||||
|
let renewed = await repository.renewUploadLease(
|
||||||
|
ownerID: ownerID,
|
||||||
|
leaseID: leaseID,
|
||||||
|
globalLeaseDuration: configuration.globalLeaseDuration,
|
||||||
|
eventLeaseDuration: configuration.eventLeaseDuration
|
||||||
|
)
|
||||||
|
guard renewed else {
|
||||||
|
await repository.releaseEvents(events, leaseID: leaseID)
|
||||||
|
logger.log(
|
||||||
|
AnalyticsUploadLogEntry(
|
||||||
|
outcome: .skipped,
|
||||||
|
eventCount: events.count,
|
||||||
|
attempt: maximumAttempt(in: events),
|
||||||
|
errorCategory: .storage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRetry(
|
||||||
|
events: [AnalyticsLeasedEvent],
|
||||||
|
leaseID: String,
|
||||||
|
response: AnalyticsHTTPResponse?,
|
||||||
|
category: AnalyticsUploadErrorCategory,
|
||||||
|
minimumDelay: TimeInterval = 0
|
||||||
|
) async {
|
||||||
|
let attempt = maximumAttempt(in: events) + 1
|
||||||
|
let exponent = min(attempt - 1, 16)
|
||||||
|
let ceiling = min(
|
||||||
|
configuration.maximumBackoff,
|
||||||
|
pow(2, Double(exponent))
|
||||||
|
)
|
||||||
|
let jitterMilliseconds = random.next(
|
||||||
|
upperBound: UInt64(max(0, ceiling * 1_000))
|
||||||
|
)
|
||||||
|
let jitter = TimeInterval(jitterMilliseconds) / 1_000
|
||||||
|
let retryAfter = response.flatMap(retryAfterDelay) ?? 0
|
||||||
|
let delay = min(
|
||||||
|
configuration.maximumBackoff,
|
||||||
|
max(minimumDelay, retryAfter, jitter)
|
||||||
|
)
|
||||||
|
await repository.scheduleRetry(
|
||||||
|
events: events,
|
||||||
|
leaseID: leaseID,
|
||||||
|
delay: delay
|
||||||
|
)
|
||||||
|
logger.log(
|
||||||
|
AnalyticsUploadLogEntry(
|
||||||
|
outcome: .retryScheduled,
|
||||||
|
eventCount: events.count,
|
||||||
|
statusCode: response?.statusCode,
|
||||||
|
attempt: attempt,
|
||||||
|
errorCategory: category
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryAfterDelay(_ response: AnalyticsHTTPResponse) -> TimeInterval? {
|
||||||
|
guard let value = response.header(named: "Retry-After")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let seconds = TimeInterval(value) {
|
||||||
|
return min(configuration.maximumBackoff, max(0, seconds))
|
||||||
|
}
|
||||||
|
guard let date = Self.httpDateFormatter.date(from: value) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return min(
|
||||||
|
configuration.maximumBackoff,
|
||||||
|
max(0, date.timeIntervalSince(clock.now()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maximumAttempt(in events: [AnalyticsLeasedEvent]) -> Int {
|
||||||
|
events.map(\.attemptCount).max() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
|
||||||
|
var body = Data(#"{"events":["#.utf8)
|
||||||
|
for index in events.indices {
|
||||||
|
if index > 0 {
|
||||||
|
body.append(UInt8(ascii: ","))
|
||||||
|
}
|
||||||
|
body.append(events[index].payload)
|
||||||
|
}
|
||||||
|
body.append(Data("]}".utf8))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func networkCategory(for error: Error) -> AnalyticsUploadErrorCategory {
|
||||||
|
guard let urlError = error as? URLError else { return .network }
|
||||||
|
switch urlError.code {
|
||||||
|
case .timedOut:
|
||||||
|
return .timeout
|
||||||
|
case .userAuthenticationRequired,
|
||||||
|
.userCancelledAuthentication:
|
||||||
|
return .authentication
|
||||||
|
default:
|
||||||
|
return .network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var httpDateFormatter: DateFormatter {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AnalyticsNetworkError: Error {
|
||||||
|
case nonHTTPResponse
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
|
|||||||
case agentPlanning = "agent_planning"
|
case agentPlanning = "agent_planning"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional server-audited purpose. A purpose may affect billing only when the
|
||||||
|
/// authenticated gateway independently verifies its eligibility.
|
||||||
|
public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
|
||||||
|
case oobe
|
||||||
|
}
|
||||||
|
|
||||||
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
|
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
|
||||||
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
||||||
|
|
||||||
@@ -143,4 +149,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
|
|||||||
let temperature: Double
|
let temperature: Double
|
||||||
let stream: Bool
|
let stream: Bool
|
||||||
let taskKind: ManagedGatewayTaskKind
|
let taskKind: ManagedGatewayTaskKind
|
||||||
|
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
|
|
||||||
public let capability: Capability
|
public let capability: Capability
|
||||||
public let taskKind: ManagedGatewayTaskKind
|
public let taskKind: ManagedGatewayTaskKind
|
||||||
|
public let requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
public let requestTimeout: TimeInterval
|
public let requestTimeout: TimeInterval
|
||||||
|
|
||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
@@ -60,6 +61,7 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
public init(
|
public init(
|
||||||
capability: Capability,
|
capability: Capability,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
grants: GatewayGrantCoordinator,
|
grants: GatewayGrantCoordinator,
|
||||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||||
session: URLSession = .shared,
|
session: URLSession = .shared,
|
||||||
@@ -68,6 +70,7 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
) {
|
) {
|
||||||
self.capability = capability
|
self.capability = capability
|
||||||
self.taskKind = taskKind ?? capability.defaultTaskKind
|
self.taskKind = taskKind ?? capability.defaultTaskKind
|
||||||
|
self.requestPurpose = requestPurpose
|
||||||
self.grants = grants
|
self.grants = grants
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.session = session
|
self.session = session
|
||||||
@@ -287,7 +290,8 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
maxOutputTokens: min(max(attempt.options.maxTokens ?? 512, 1), 4_096),
|
maxOutputTokens: min(max(attempt.options.maxTokens ?? 512, 1), 4_096),
|
||||||
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
||||||
stream: stream,
|
stream: stream,
|
||||||
taskKind: taskKind
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose
|
||||||
)
|
)
|
||||||
|
|
||||||
var request = URLRequest(
|
var request = URLRequest(
|
||||||
@@ -419,6 +423,7 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
public enum ManagedGatewayLLMClientFactory {
|
public enum ManagedGatewayLLMClientFactory {
|
||||||
public static func polish(
|
public static func polish(
|
||||||
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
grants: GatewayGrantCoordinator,
|
grants: GatewayGrantCoordinator,
|
||||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||||
session: URLSession = .shared
|
session: URLSession = .shared
|
||||||
@@ -426,6 +431,7 @@ public enum ManagedGatewayLLMClientFactory {
|
|||||||
ManagedLLMClient(
|
ManagedLLMClient(
|
||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
grants: grants,
|
grants: grants,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
session: session
|
session: session
|
||||||
|
|||||||
@@ -193,11 +193,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeClient(taskKind: ManagedGatewayTaskKind? = nil) -> LLMClient {
|
public func makeClient(
|
||||||
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil
|
||||||
|
) -> LLMClient {
|
||||||
if credentialSource == .managed {
|
if credentialSource == .managed {
|
||||||
return ManagedLLMClient(
|
return ManagedLLMClient(
|
||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
grants: GatewayGrantCoordinator()
|
grants: GatewayGrantCoordinator()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
case submitAIQuestion
|
case submitAIQuestion
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire version that includes managed-gateway AI task intent.
|
/// Wire version that includes managed-gateway request purpose.
|
||||||
public static let currentProtocolVersion = 6
|
public static let currentProtocolVersion = 7
|
||||||
|
|
||||||
public let protocolVersion: Int
|
public let protocolVersion: Int
|
||||||
public let sessionId: UUID
|
public let sessionId: UUID
|
||||||
@@ -43,6 +43,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
public let aiQuestionText: String?
|
public let aiQuestionText: String?
|
||||||
/// Fine-grained managed-gateway intent for AI question submissions.
|
/// Fine-grained managed-gateway intent for AI question submissions.
|
||||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||||
|
/// Optional server-audited purpose for managed gateway billing policy.
|
||||||
|
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||||
public let aiThinkingEnabled: Bool?
|
public let aiThinkingEnabled: Bool?
|
||||||
/// Absolute wall-clock deadlines survive extension reconstruction.
|
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||||
@@ -65,6 +67,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
aiConversationID: UUID? = nil,
|
aiConversationID: UUID? = nil,
|
||||||
aiQuestionText: String? = nil,
|
aiQuestionText: String? = nil,
|
||||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
aiThinkingEnabled: Bool? = nil,
|
aiThinkingEnabled: Bool? = nil,
|
||||||
startDeadlineAt: TimeInterval? = nil,
|
startDeadlineAt: TimeInterval? = nil,
|
||||||
processingDeadlineAt: TimeInterval? = nil
|
processingDeadlineAt: TimeInterval? = nil
|
||||||
@@ -84,6 +87,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
self.aiConversationID = aiConversationID
|
self.aiConversationID = aiConversationID
|
||||||
self.aiQuestionText = aiQuestionText
|
self.aiQuestionText = aiQuestionText
|
||||||
self.aiTaskKind = aiTaskKind
|
self.aiTaskKind = aiTaskKind
|
||||||
|
self.managedRequestPurpose = managedRequestPurpose
|
||||||
self.aiThinkingEnabled = aiThinkingEnabled
|
self.aiThinkingEnabled = aiThinkingEnabled
|
||||||
self.startDeadlineAt = startDeadlineAt
|
self.startDeadlineAt = startDeadlineAt
|
||||||
self.processingDeadlineAt = processingDeadlineAt
|
self.processingDeadlineAt = processingDeadlineAt
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
public let aiQuestionText: String?
|
public let aiQuestionText: String?
|
||||||
/// Fine-grained managed-gateway intent. Regular questions keep the default.
|
/// Fine-grained managed-gateway intent. Regular questions keep the default.
|
||||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||||
|
/// Optional server-audited purpose for managed gateway billing policy.
|
||||||
|
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||||
public let aiThinkingEnabled: Bool?
|
public let aiThinkingEnabled: Bool?
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
aiConversationID: UUID? = nil,
|
aiConversationID: UUID? = nil,
|
||||||
aiQuestionText: String? = nil,
|
aiQuestionText: String? = nil,
|
||||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
aiThinkingEnabled: Bool? = nil
|
aiThinkingEnabled: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -37,6 +40,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
self.aiConversationID = aiConversationID
|
self.aiConversationID = aiConversationID
|
||||||
self.aiQuestionText = aiQuestionText
|
self.aiQuestionText = aiQuestionText
|
||||||
self.aiTaskKind = aiTaskKind
|
self.aiTaskKind = aiTaskKind
|
||||||
|
self.managedRequestPurpose = managedRequestPurpose
|
||||||
self.aiThinkingEnabled = aiThinkingEnabled
|
self.aiThinkingEnabled = aiThinkingEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,22 +114,33 @@ public struct AIQuestionService: Sendable {
|
|||||||
private let client: any LLMClient
|
private let client: any LLMClient
|
||||||
private let conversations: AIConversationStore
|
private let conversations: AIConversationStore
|
||||||
private let responseLength: AIResponseLength
|
private let responseLength: AIResponseLength
|
||||||
|
private let analyticsClient: any AnalyticsClient
|
||||||
|
private let analyticsFeature: AnalyticsFeature
|
||||||
|
private let analyticsExecutionMode: AnalyticsExecutionMode
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
client: any LLMClient,
|
client: any LLMClient,
|
||||||
conversations: AIConversationStore,
|
conversations: AIConversationStore,
|
||||||
responseLength: AIResponseLength = .default
|
responseLength: AIResponseLength = .default,
|
||||||
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
|
analyticsFeature: AnalyticsFeature = .aiAssistant,
|
||||||
|
analyticsExecutionMode: AnalyticsExecutionMode = .byok
|
||||||
) {
|
) {
|
||||||
self.client = client
|
self.client = client
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
self.responseLength = responseLength
|
self.responseLength = responseLength
|
||||||
|
self.analyticsClient = analyticsClient
|
||||||
|
self.analyticsFeature = analyticsFeature
|
||||||
|
self.analyticsExecutionMode = analyticsExecutionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func configured(
|
public static func configured(
|
||||||
store: any ConfigurationStore,
|
store: any ConfigurationStore,
|
||||||
conversations: AIConversationStore,
|
conversations: AIConversationStore,
|
||||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||||
thinkingEnabled: Bool = true
|
thinkingEnabled: Bool = true,
|
||||||
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
|
analyticsFeature: AnalyticsFeature = .aiAssistant
|
||||||
) throws -> AIQuestionService {
|
) throws -> AIQuestionService {
|
||||||
if store.credentialSource == .managed {
|
if store.credentialSource == .managed {
|
||||||
return AIQuestionService(
|
return AIQuestionService(
|
||||||
@@ -139,7 +150,10 @@ public struct AIQuestionService: Sendable {
|
|||||||
grants: GatewayGrantCoordinator()
|
grants: GatewayGrantCoordinator()
|
||||||
),
|
),
|
||||||
conversations: conversations,
|
conversations: conversations,
|
||||||
responseLength: store.aiResponseLength
|
responseLength: store.aiResponseLength,
|
||||||
|
analyticsClient: analyticsClient,
|
||||||
|
analyticsFeature: analyticsFeature,
|
||||||
|
analyticsExecutionMode: .managed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Same provider + baseURL + model resolution as dictation polish so the
|
// Same provider + baseURL + model resolution as dictation polish so the
|
||||||
@@ -172,7 +186,10 @@ public struct AIQuestionService: Sendable {
|
|||||||
thinkingEnabled: thinkingEnabled
|
thinkingEnabled: thinkingEnabled
|
||||||
),
|
),
|
||||||
conversations: conversations,
|
conversations: conversations,
|
||||||
responseLength: store.aiResponseLength
|
responseLength: store.aiResponseLength,
|
||||||
|
analyticsClient: analyticsClient,
|
||||||
|
analyticsFeature: analyticsFeature,
|
||||||
|
analyticsExecutionMode: .byok
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +216,11 @@ public struct AIQuestionService: Sendable {
|
|||||||
maxTokens: Self.outputTokenLimit
|
maxTokens: Self.outputTokenLimit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let operation = analyticsClient.startAIFeature(
|
||||||
|
analyticsFeature,
|
||||||
|
executionMode: analyticsExecutionMode
|
||||||
|
)
|
||||||
|
do {
|
||||||
var accumulated = ""
|
var accumulated = ""
|
||||||
for try await event in client.completeStreaming(
|
for try await event in client.completeStreaming(
|
||||||
messages: messages,
|
messages: messages,
|
||||||
@@ -220,7 +242,12 @@ public struct AIQuestionService: Sendable {
|
|||||||
|
|
||||||
let answer = Self.boundedAnswer(accumulated)
|
let answer = Self.boundedAnswer(accumulated)
|
||||||
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
|
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
|
||||||
|
operation.succeed()
|
||||||
return answer
|
return answer
|
||||||
|
} catch {
|
||||||
|
operation.fail(category: Self.analyticsFailureCategory(for: error))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commit only after the host wins the utterance terminal claim. Keeping
|
/// Commit only after the host wins the utterance terminal claim. Keeping
|
||||||
@@ -254,6 +281,42 @@ public struct AIQuestionService: Sendable {
|
|||||||
return String(prefix.dropLast()) + "…"
|
return String(prefix.dropLast()) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func analyticsFailureCategory(
|
||||||
|
for error: Error
|
||||||
|
) -> AnalyticsFailureCategory {
|
||||||
|
if error is CancellationError {
|
||||||
|
return .cancelled
|
||||||
|
}
|
||||||
|
if error is ServiceError {
|
||||||
|
return .validation
|
||||||
|
}
|
||||||
|
if let error = error as? ManagedGatewayError {
|
||||||
|
switch error {
|
||||||
|
case .insufficientCredits:
|
||||||
|
return .insufficientCredits
|
||||||
|
case .timeout:
|
||||||
|
return .timeout
|
||||||
|
case .missingGrant, .scopeNotGranted, .invalidGrant:
|
||||||
|
return .validation
|
||||||
|
case .server:
|
||||||
|
return .provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error = error as? LLMError {
|
||||||
|
switch error {
|
||||||
|
case .cancelled:
|
||||||
|
return .cancelled
|
||||||
|
case .transport, .rateLimited:
|
||||||
|
return .network
|
||||||
|
case .invalidURL, .noAPIKey, .decoding:
|
||||||
|
return .validation
|
||||||
|
case .http:
|
||||||
|
return .provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
/// Soft cap for live drafts — no ellipsis mid-stream.
|
/// Soft cap for live drafts — no ellipsis mid-stream.
|
||||||
public static func streamingPreview(_ value: String) -> String {
|
public static func streamingPreview(_ value: String) -> String {
|
||||||
if value.count <= AIQuestionLimits.maximumAnswerCharacterCount {
|
if value.count <= AIQuestionLimits.maximumAnswerCharacterCount {
|
||||||
|
|||||||
@@ -394,7 +394,13 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Client
|
// MARK: - Client
|
||||||
|
|
||||||
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
public func makeClient(
|
||||||
configuration.makeClient(taskKind: taskKind)
|
taskKind: ManagedGatewayTaskKind?,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
) -> LLMClient {
|
||||||
|
configuration.makeClient(
|
||||||
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ public enum KeyboardSetupBridge {
|
|||||||
private enum Key {
|
private enum Key {
|
||||||
static let fullAccessReady = "keyboard.extension.fullAccessReady"
|
static let fullAccessReady = "keyboard.extension.fullAccessReady"
|
||||||
static let lastSeenAt = "keyboard.extension.lastSeenAt"
|
static let lastSeenAt = "keyboard.extension.lastSeenAt"
|
||||||
|
static let onboardingPracticeExpiresAt = "keyboard.onboarding.practiceExpiresAt"
|
||||||
|
static let lastVoiceInsertionAt = "keyboard.extension.lastVoiceInsertionAt"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the keyboard extension last appeared with Full Access enabled.
|
/// True when the keyboard extension last appeared with Full Access enabled.
|
||||||
@@ -19,11 +21,72 @@ public enum KeyboardSetupBridge {
|
|||||||
return AppGroup.defaults.bool(forKey: Key.fullAccessReady)
|
return AppGroup.defaults.bool(forKey: Key.fullAccessReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True after the extension has appeared at least once. Unlike
|
||||||
|
/// `isReadyForOnboardingSkip`, this also covers an appearance without Full
|
||||||
|
/// Access so the host can explain the missing setting precisely.
|
||||||
|
public static var hasAppeared: Bool {
|
||||||
|
guard AppGroup.isAvailable else { return false }
|
||||||
|
return AppGroup.defaults.double(forKey: Key.lastSeenAt) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short-lived exception that lets the real keyboard complete its first
|
||||||
|
/// voice insertion while the host still owns the onboarding screen.
|
||||||
|
public static var isOnboardingPracticeActive: Bool {
|
||||||
|
onboardingPracticeIsActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wall clock of the most recent voice insertion issued by the extension.
|
||||||
|
/// The host compares this with the current practice start time, so an old
|
||||||
|
/// insertion can never complete a new onboarding run.
|
||||||
|
public static var lastVoiceInsertionAt: Date? {
|
||||||
|
guard AppGroup.isAvailable else { return nil }
|
||||||
|
let value = AppGroup.defaults.double(forKey: Key.lastVoiceInsertionAt)
|
||||||
|
return value > 0 ? Date(timeIntervalSince1970: value) : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func onboardingPracticeIsActive(
|
||||||
|
defaults: UserDefaults? = nil,
|
||||||
|
now: Date = Date()
|
||||||
|
) -> Bool {
|
||||||
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return false }
|
||||||
|
return store.double(forKey: Key.onboardingPracticeExpiresAt) > now.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setOnboardingPracticeActive(
|
||||||
|
_ active: Bool,
|
||||||
|
duration: TimeInterval = 30 * 60,
|
||||||
|
defaults: UserDefaults? = nil,
|
||||||
|
now: Date = Date()
|
||||||
|
) {
|
||||||
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||||
|
if active {
|
||||||
|
store.set(
|
||||||
|
now.addingTimeInterval(duration).timeIntervalSince1970,
|
||||||
|
forKey: Key.onboardingPracticeExpiresAt
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
store.removeObject(forKey: Key.onboardingPracticeExpiresAt)
|
||||||
|
}
|
||||||
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
|
}
|
||||||
|
|
||||||
/// Called from the keyboard extension on each appearance.
|
/// Called from the keyboard extension on each appearance.
|
||||||
public static func markExtensionAppearance(hasFullAccess: Bool) {
|
public static func markExtensionAppearance(hasFullAccess: Bool) {
|
||||||
guard AppGroup.isAvailable else { return }
|
guard AppGroup.isAvailable else { return }
|
||||||
let defaults = AppGroup.defaults
|
let defaults = AppGroup.defaults
|
||||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt)
|
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt)
|
||||||
defaults.set(hasFullAccess, forKey: Key.fullAccessReady)
|
defaults.set(hasFullAccess, forKey: Key.fullAccessReady)
|
||||||
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called only after a Flow transcript has been inserted into the host
|
||||||
|
/// field, not when recognition merely produced a result.
|
||||||
|
public static func markVoiceInsertion() {
|
||||||
|
guard AppGroup.isAvailable else { return }
|
||||||
|
AppGroup.defaults.set(
|
||||||
|
Date().timeIntervalSince1970,
|
||||||
|
forKey: Key.lastVoiceInsertionAt
|
||||||
|
)
|
||||||
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var flowSessionActive: Bool = false
|
@Published public var flowSessionActive: Bool = false
|
||||||
/// Unified mic color / tap / hint source for the keyboard extension.
|
/// Unified mic color / tap / hint source for the keyboard extension.
|
||||||
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
||||||
|
/// Short-lived host-owned practice mode. It unlocks real dictation before
|
||||||
|
/// onboarding completion, but only while the onboarding text field is live.
|
||||||
|
@Published public var isOnboardingPracticeActive: Bool = false
|
||||||
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
||||||
/// selected but the provider-specific API key is missing).
|
/// selected but the provider-specific API key is missing).
|
||||||
@Published public var micDisabled: Bool = false
|
@Published public var micDisabled: Bool = false
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public actor PolishingService {
|
|||||||
let systemPrompt: String?
|
let systemPrompt: String?
|
||||||
let providerIdOverride: String?
|
let providerIdOverride: String?
|
||||||
let taskKind: ManagedGatewayTaskKind?
|
let taskKind: ManagedGatewayTaskKind?
|
||||||
|
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
let context: PolishContext?
|
let context: PolishContext?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ public actor PolishingService {
|
|||||||
|
|
||||||
private let store: any ConfigurationStore
|
private let store: any ConfigurationStore
|
||||||
private let timeout: TimeInterval
|
private let timeout: TimeInterval
|
||||||
|
private let analyticsClient: any AnalyticsClient
|
||||||
/// Optional injected client (mostly for testing). When nil we build
|
/// Optional injected client (mostly for testing). When nil we build
|
||||||
/// one from `store.makeClient()` per call.
|
/// one from `store.makeClient()` per call.
|
||||||
private let injectedClient: LLMClient?
|
private let injectedClient: LLMClient?
|
||||||
@@ -91,11 +93,13 @@ public actor PolishingService {
|
|||||||
public init(
|
public init(
|
||||||
store: any ConfigurationStore = AppGroupStore(),
|
store: any ConfigurationStore = AppGroupStore(),
|
||||||
client: LLMClient? = nil,
|
client: LLMClient? = nil,
|
||||||
timeout: TimeInterval? = nil
|
timeout: TimeInterval? = nil,
|
||||||
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
|
||||||
) {
|
) {
|
||||||
self.store = store
|
self.store = store
|
||||||
self.injectedClient = client
|
self.injectedClient = client
|
||||||
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
|
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
|
||||||
|
self.analyticsClient = analyticsClient
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context-aware polish entry point. The optional
|
/// Context-aware polish entry point. The optional
|
||||||
@@ -110,6 +114,7 @@ public actor PolishingService {
|
|||||||
systemPrompt: String? = nil,
|
systemPrompt: String? = nil,
|
||||||
providerIdOverride: String? = nil,
|
providerIdOverride: String? = nil,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
context: PolishContext? = nil
|
context: PolishContext? = nil
|
||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
try await performPolish(
|
try await performPolish(
|
||||||
@@ -119,6 +124,7 @@ public actor PolishingService {
|
|||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
providerIdOverride: providerIdOverride,
|
providerIdOverride: providerIdOverride,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
).text
|
).text
|
||||||
@@ -132,6 +138,7 @@ public actor PolishingService {
|
|||||||
systemPrompt: String? = nil,
|
systemPrompt: String? = nil,
|
||||||
providerIdOverride: String? = nil,
|
providerIdOverride: String? = nil,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
context: PolishContext? = nil
|
context: PolishContext? = nil
|
||||||
) async throws -> PolishOutcome {
|
) async throws -> PolishOutcome {
|
||||||
try await performPolish(
|
try await performPolish(
|
||||||
@@ -141,6 +148,7 @@ public actor PolishingService {
|
|||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
providerIdOverride: providerIdOverride,
|
providerIdOverride: providerIdOverride,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -152,6 +160,7 @@ public actor PolishingService {
|
|||||||
let systemPrompt = request.systemPrompt
|
let systemPrompt = request.systemPrompt
|
||||||
let providerIdOverride = request.providerIdOverride
|
let providerIdOverride = request.providerIdOverride
|
||||||
let taskKind = request.taskKind
|
let taskKind = request.taskKind
|
||||||
|
let requestPurpose = request.requestPurpose
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||||
|
|
||||||
@@ -185,14 +194,26 @@ public actor PolishingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let remoteResult = try await polishRemote(
|
let operation = analyticsClient.startAIFeature(
|
||||||
|
.polish,
|
||||||
|
executionMode: analyticsExecutionMode
|
||||||
|
)
|
||||||
|
let remoteResult: RemotePolishResult
|
||||||
|
do {
|
||||||
|
remoteResult = try await polishRemote(
|
||||||
trimmed,
|
trimmed,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
providerIdOverride: providerIdOverride,
|
providerIdOverride: providerIdOverride,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
context: resolvedContext
|
context: resolvedContext
|
||||||
)
|
)
|
||||||
|
operation.succeed()
|
||||||
|
} catch {
|
||||||
|
operation.fail(category: Self.analyticsFailureCategory(for: error))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
// Translation and custom prompts bypass the polish post-processor.
|
// Translation and custom prompts bypass the polish post-processor.
|
||||||
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
|
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
|
||||||
@@ -214,6 +235,51 @@ public actor PolishingService {
|
|||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var analyticsExecutionMode: AnalyticsExecutionMode {
|
||||||
|
store.credentialSource == .managed ? .managed : .byok
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func analyticsFailureCategory(
|
||||||
|
for error: Error
|
||||||
|
) -> AnalyticsFailureCategory {
|
||||||
|
if error is CancellationError {
|
||||||
|
return .cancelled
|
||||||
|
}
|
||||||
|
if let error = error as? PolishError {
|
||||||
|
switch error {
|
||||||
|
case .timeout:
|
||||||
|
return .timeout
|
||||||
|
case .noTranscript, .missingAPIKey, .keychainLocked:
|
||||||
|
return .validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error = error as? ManagedGatewayError {
|
||||||
|
switch error {
|
||||||
|
case .insufficientCredits:
|
||||||
|
return .insufficientCredits
|
||||||
|
case .timeout:
|
||||||
|
return .timeout
|
||||||
|
case .missingGrant, .scopeNotGranted, .invalidGrant:
|
||||||
|
return .validation
|
||||||
|
case .server:
|
||||||
|
return .provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error = error as? LLMError {
|
||||||
|
switch error {
|
||||||
|
case .cancelled:
|
||||||
|
return .cancelled
|
||||||
|
case .transport, .rateLimited:
|
||||||
|
return .network
|
||||||
|
case .invalidURL, .noAPIKey, .decoding:
|
||||||
|
return .validation
|
||||||
|
case .http:
|
||||||
|
return .provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
|
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .polish:
|
case .polish:
|
||||||
@@ -229,6 +295,7 @@ public actor PolishingService {
|
|||||||
systemPrompt: String? = nil,
|
systemPrompt: String? = nil,
|
||||||
providerIdOverride: String? = nil,
|
providerIdOverride: String? = nil,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
context: PolishContext
|
context: PolishContext
|
||||||
) async throws -> RemotePolishResult {
|
) async throws -> RemotePolishResult {
|
||||||
let effectiveProviderId = Self.resolvedProviderId(
|
let effectiveProviderId = Self.resolvedProviderId(
|
||||||
@@ -240,7 +307,8 @@ public actor PolishingService {
|
|||||||
client = injectedClient
|
client = injectedClient
|
||||||
} else if store.credentialSource == .managed {
|
} else if store.credentialSource == .managed {
|
||||||
client = store.makeClient(
|
client = store.makeClient(
|
||||||
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode)
|
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode),
|
||||||
|
requestPurpose: requestPurpose
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// AnalyticsAIOperationTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
//
|
||||||
|
// Ordering, duration, and exactly-once terminal semantics for AI operations.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class AnalyticsAIOperationTests: XCTestCase {
|
||||||
|
func testStartedIsPersistedBeforeTerminalWithDurationBucket() async throws {
|
||||||
|
let monotonicClock = AnalyticsTestMonotonicClock(1_000)
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: AnalyticsTestWallClock(),
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
let client = LiveAnalyticsClient(
|
||||||
|
repository: repository,
|
||||||
|
context: analyticsTestContext,
|
||||||
|
monotonicClock: monotonicClock
|
||||||
|
)
|
||||||
|
|
||||||
|
let operation = client.startAIFeature(.polish, executionMode: .managed)
|
||||||
|
monotonicClock.set(4_000_001_000)
|
||||||
|
operation.succeed()
|
||||||
|
|
||||||
|
_ = await analyticsWaitForPendingEventCount(2, repository: repository)
|
||||||
|
let events = try await analyticsDecodePendingEvents(repository: repository)
|
||||||
|
XCTAssertEqual(events.map(\.eventType), [.aiFeatureStarted, .aiFeatureSucceeded])
|
||||||
|
XCTAssertEqual(events.map(\.feature), [.polish, .polish])
|
||||||
|
XCTAssertEqual(events.map(\.executionMode), [.managed, .managed])
|
||||||
|
XCTAssertNil(events[0].durationBucket)
|
||||||
|
XCTAssertEqual(events[1].durationBucket, .threeToTenSeconds)
|
||||||
|
XCTAssertNil(events[1].failureCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConcurrentTerminalCallsPersistExactlyOneTerminalEvent() async throws {
|
||||||
|
let monotonicClock = AnalyticsTestMonotonicClock(10)
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: AnalyticsTestWallClock(),
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
let client = LiveAnalyticsClient(
|
||||||
|
repository: repository,
|
||||||
|
context: analyticsTestContext,
|
||||||
|
monotonicClock: monotonicClock
|
||||||
|
)
|
||||||
|
let operation = client.startAIFeature(.aiAssistant, executionMode: .local)
|
||||||
|
monotonicClock.set(2_000_000_010)
|
||||||
|
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for index in 0..<60 {
|
||||||
|
group.addTask {
|
||||||
|
switch index % 3 {
|
||||||
|
case 0:
|
||||||
|
operation.succeed()
|
||||||
|
case 1:
|
||||||
|
operation.fail(category: .provider)
|
||||||
|
default:
|
||||||
|
operation.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = await analyticsWaitForPendingEventCount(2, repository: repository)
|
||||||
|
let events = try await analyticsDecodePendingEvents(repository: repository)
|
||||||
|
XCTAssertEqual(events.first?.eventType, .aiFeatureStarted)
|
||||||
|
XCTAssertEqual(events.count, 2)
|
||||||
|
let terminalEvents = events.filter {
|
||||||
|
[.aiFeatureSucceeded, .aiFeatureFailed].contains($0.eventType)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(terminalEvents.count, 1)
|
||||||
|
XCTAssertEqual(terminalEvents.first?.feature, .aiAssistant)
|
||||||
|
XCTAssertEqual(terminalEvents.first?.executionMode, .local)
|
||||||
|
XCTAssertEqual(terminalEvents.first?.durationBucket, .oneToThreeSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// AnalyticsModelTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
//
|
||||||
|
// Wire-contract and privacy invariants for the allowlisted analytics schema.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class AnalyticsModelTests: XCTestCase {
|
||||||
|
func testEventEncodingContainsOnlyAllowlistedKeysAndNoFreeTextContainer() throws {
|
||||||
|
let event = try AnalyticsEvent(
|
||||||
|
installationId: analyticsTestUUID(1),
|
||||||
|
clientEventId: analyticsTestUUID(2),
|
||||||
|
eventType: .aiFeatureFailed,
|
||||||
|
occurredAt: Date(timeIntervalSince1970: 1_700_000_000.125),
|
||||||
|
surface: .keyboard,
|
||||||
|
appVersion: "2.0.0",
|
||||||
|
osVersion: "26.0",
|
||||||
|
feature: .polish,
|
||||||
|
executionMode: .managed,
|
||||||
|
failureCategory: .network,
|
||||||
|
durationBucket: .oneToThreeSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(event)
|
||||||
|
let object = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
Set(object.keys),
|
||||||
|
[
|
||||||
|
"installationId",
|
||||||
|
"clientEventId",
|
||||||
|
"eventType",
|
||||||
|
"occurredAt",
|
||||||
|
"surface",
|
||||||
|
"appVersion",
|
||||||
|
"osVersion",
|
||||||
|
"feature",
|
||||||
|
"executionMode",
|
||||||
|
"failureCategory",
|
||||||
|
"durationBucket"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
XCTAssertFalse(object.keys.contains("properties"))
|
||||||
|
XCTAssertFalse(object.keys.contains("text"))
|
||||||
|
XCTAssertEqual(
|
||||||
|
Set(Mirror(reflecting: event).children.compactMap(\.label)),
|
||||||
|
Set(object.keys).union(["acquisitionChannel"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownFieldsAreRejectedAtEveryWireEnvelope() throws {
|
||||||
|
let event = try AnalyticsEvent(
|
||||||
|
installationId: analyticsTestUUID(1),
|
||||||
|
clientEventId: analyticsTestUUID(2),
|
||||||
|
eventType: .sessionStarted,
|
||||||
|
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
surface: .app,
|
||||||
|
appVersion: "2.0.0",
|
||||||
|
osVersion: "26.0"
|
||||||
|
)
|
||||||
|
var eventObject = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(event))
|
||||||
|
as? [String: Any]
|
||||||
|
)
|
||||||
|
eventObject["userText"] = "must be rejected"
|
||||||
|
assertUnknownField(
|
||||||
|
try JSONSerialization.data(withJSONObject: eventObject),
|
||||||
|
as: AnalyticsEvent.self,
|
||||||
|
expected: "userText"
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = Data(#"{"events":[],"properties":{"secret":"value"}}"#.utf8)
|
||||||
|
assertUnknownField(
|
||||||
|
request,
|
||||||
|
as: AnalyticsUploadRequest.self,
|
||||||
|
expected: "properties"
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = Data(#"{"accepted":1,"replayed":0,"debug":"secret"}"#.utf8)
|
||||||
|
assertUnknownField(
|
||||||
|
response,
|
||||||
|
as: AnalyticsUploadResponse.self,
|
||||||
|
expected: "debug"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnvironmentFiltersAndTruncatesVersionsToSafeBound() {
|
||||||
|
let environment = AnalyticsEnvironment(
|
||||||
|
appVersion: String(repeating: "a", count: 40) + "/private",
|
||||||
|
osVersion: "26.0 (Build 23A)/用户"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(environment.appVersion, String(repeating: "a", count: 32))
|
||||||
|
XCTAssertEqual(environment.appVersion.utf8.count, 32)
|
||||||
|
XCTAssertEqual(environment.osVersion, "26.0Build23A")
|
||||||
|
|
||||||
|
let empty = AnalyticsEnvironment(appVersion: "用户 /", osVersion: "")
|
||||||
|
XCTAssertEqual(empty.appVersion, "unknown")
|
||||||
|
XCTAssertEqual(empty.osVersion, "unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPurchaseCancelledRequiresCancelledFailureCategory() throws {
|
||||||
|
XCTAssertNoThrow(
|
||||||
|
try AnalyticsEvent(
|
||||||
|
installationId: analyticsTestUUID(1),
|
||||||
|
clientEventId: analyticsTestUUID(2),
|
||||||
|
eventType: .purchaseCancelled,
|
||||||
|
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
surface: .app,
|
||||||
|
appVersion: "2.0.0",
|
||||||
|
osVersion: "26.0",
|
||||||
|
failureCategory: .cancelled
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try AnalyticsEvent(
|
||||||
|
installationId: analyticsTestUUID(1),
|
||||||
|
clientEventId: analyticsTestUUID(3),
|
||||||
|
eventType: .purchaseCancelled,
|
||||||
|
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
surface: .app,
|
||||||
|
appVersion: "2.0.0",
|
||||||
|
osVersion: "26.0",
|
||||||
|
failureCategory: .network
|
||||||
|
)
|
||||||
|
) { error in
|
||||||
|
guard case AnalyticsModelError.invalidDimensions(.purchaseCancelled) = error else {
|
||||||
|
return XCTFail("Unexpected error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDurationBucketBoundariesAreStable() {
|
||||||
|
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 999_999_999), .lessThanOneSecond)
|
||||||
|
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 1_000_000_000), .oneToThreeSeconds)
|
||||||
|
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 2_999_999_999), .oneToThreeSeconds)
|
||||||
|
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 3_000_000_000), .threeToTenSeconds)
|
||||||
|
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 10_000_000_000), .tenToThirtySeconds)
|
||||||
|
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 30_000_000_000), .thirtySecondsOrMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertUnknownField<T: Decodable>(
|
||||||
|
_ data: Data,
|
||||||
|
as type: T.Type,
|
||||||
|
expected: String,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try JSONDecoder().decode(type, from: data),
|
||||||
|
file: file,
|
||||||
|
line: line
|
||||||
|
) { error in
|
||||||
|
guard case AnalyticsModelError.unknownField(let field) = error else {
|
||||||
|
return XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(field, expected, file: file, line: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
// AnalyticsRepositoryTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
//
|
||||||
|
// Durable identity, queue policy, retention, and cross-process lease coverage.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class AnalyticsRepositoryTests: XCTestCase {
|
||||||
|
func testFirstOpenMarkerAndEventAreCreatedExactlyOnceAcrossRestart() async throws {
|
||||||
|
let url = try analyticsTemporaryDatabaseURL()
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
|
||||||
|
await repository.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .referral
|
||||||
|
)
|
||||||
|
await repository.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .socialContent
|
||||||
|
)
|
||||||
|
|
||||||
|
let firstSnapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertTrue(firstSnapshot.firstOpenRecorded)
|
||||||
|
XCTAssertEqual(firstSnapshot.pendingEvents.map(\.eventType), [.firstOpen])
|
||||||
|
let decodedEvents = try await analyticsDecodePendingEvents(repository: repository)
|
||||||
|
let event = try XCTUnwrap(decodedEvents.first)
|
||||||
|
XCTAssertEqual(event.installationId, analyticsTestUUID(1))
|
||||||
|
XCTAssertEqual(event.clientEventId, analyticsTestUUID(2))
|
||||||
|
XCTAssertEqual(event.acquisitionChannel, .referral)
|
||||||
|
|
||||||
|
let restarted = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
|
||||||
|
)
|
||||||
|
await restarted.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .appStoreOrganic
|
||||||
|
)
|
||||||
|
|
||||||
|
let restartedSnapshot = await restarted.debugSnapshot()
|
||||||
|
XCTAssertEqual(restartedSnapshot.installationID, analyticsTestUUID(1))
|
||||||
|
XCTAssertEqual(restartedSnapshot.pendingEvents.map(\.eventType), [.firstOpen])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSessionWindowTracksThirtyMinutesOfInactivityPerSurface() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
|
||||||
|
await repository.recordSessionIfNeeded(context: analyticsTestContext)
|
||||||
|
clock.advance(by: 29 * 60)
|
||||||
|
await repository.recordSessionIfNeeded(context: analyticsTestContext)
|
||||||
|
clock.advance(by: 29 * 60)
|
||||||
|
await repository.recordSessionIfNeeded(context: analyticsTestContext)
|
||||||
|
|
||||||
|
var snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.sessionStarted])
|
||||||
|
|
||||||
|
clock.advance(by: 30 * 60)
|
||||||
|
await repository.recordSessionIfNeeded(context: analyticsTestContext)
|
||||||
|
snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertEqual(
|
||||||
|
snapshot.pendingEvents.compactMap(\.eventType),
|
||||||
|
[.sessionStarted, .sessionStarted]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisableClearsQueuePersistsAcrossRestartAndReenableRotatesIdentity() async throws {
|
||||||
|
let url = try analyticsTemporaryDatabaseURL()
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await repository.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .unknown
|
||||||
|
)
|
||||||
|
let originalID = await repository.debugSnapshot().installationID
|
||||||
|
|
||||||
|
await repository.setEnabled(false)
|
||||||
|
var snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertFalse(snapshot.enabled)
|
||||||
|
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||||
|
|
||||||
|
let restarted = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
|
||||||
|
)
|
||||||
|
let remainsDisabled = await restarted.isEnabled()
|
||||||
|
XCTAssertFalse(remainsDisabled)
|
||||||
|
await restarted.record(
|
||||||
|
eventType: .keyboardActivated,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
let disabledSnapshot = await restarted.debugSnapshot()
|
||||||
|
XCTAssertTrue(disabledSnapshot.pendingEvents.isEmpty)
|
||||||
|
|
||||||
|
await restarted.setEnabled(true)
|
||||||
|
await restarted.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .referral
|
||||||
|
)
|
||||||
|
await restarted.record(
|
||||||
|
eventType: .keyboardActivated,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
snapshot = await restarted.debugSnapshot()
|
||||||
|
XCTAssertTrue(snapshot.enabled)
|
||||||
|
XCTAssertNotEqual(snapshot.installationID, originalID)
|
||||||
|
XCTAssertTrue(snapshot.firstOpenRecorded)
|
||||||
|
XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.keyboardActivated])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAccountObservationSwitchAndDeletionRotateWithoutRepeatingFirstOpen() async throws {
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: AnalyticsTestWallClock(),
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await repository.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .appStoreOrganic
|
||||||
|
)
|
||||||
|
let installationBeforeAccount = await repository.debugSnapshot().installationID
|
||||||
|
|
||||||
|
let first = await repository.observeAccount(stableIdentifier: "account-a")
|
||||||
|
guard case .firstAccount = first else {
|
||||||
|
return XCTFail("Expected firstAccount, got \(first)")
|
||||||
|
}
|
||||||
|
let installationAfterFirstAccount = await repository.debugSnapshot().installationID
|
||||||
|
XCTAssertEqual(installationAfterFirstAccount, installationBeforeAccount)
|
||||||
|
|
||||||
|
let unchanged = await repository.observeAccount(stableIdentifier: "account-a")
|
||||||
|
guard case .unchanged = unchanged else {
|
||||||
|
return XCTFail("Expected unchanged, got \(unchanged)")
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.record(
|
||||||
|
eventType: .keyboardActivated,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
let switched = await repository.observeAccount(stableIdentifier: "account-b")
|
||||||
|
guard case .switchedAccount = switched else {
|
||||||
|
return XCTFail("Expected switchedAccount, got \(switched)")
|
||||||
|
}
|
||||||
|
let afterSwitch = await repository.debugSnapshot()
|
||||||
|
XCTAssertNotEqual(afterSwitch.installationID, installationBeforeAccount)
|
||||||
|
XCTAssertTrue(afterSwitch.pendingEvents.isEmpty)
|
||||||
|
XCTAssertTrue(afterSwitch.firstOpenRecorded)
|
||||||
|
|
||||||
|
await repository.record(
|
||||||
|
eventType: .purchaseViewed,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
let switchedID = afterSwitch.installationID
|
||||||
|
await repository.handleAccountDeletion()
|
||||||
|
let afterDeletion = await repository.debugSnapshot()
|
||||||
|
XCTAssertNotEqual(afterDeletion.installationID, switchedID)
|
||||||
|
XCTAssertTrue(afterDeletion.pendingEvents.isEmpty)
|
||||||
|
XCTAssertTrue(afterDeletion.firstOpenRecorded)
|
||||||
|
|
||||||
|
let firstAfterDeletion = await repository.observeAccount(stableIdentifier: "account-b")
|
||||||
|
guard case .firstAccount = firstAfterDeletion else {
|
||||||
|
return XCTFail("Expected firstAccount after deletion, got \(firstAfterDeletion)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetryPreservesCanonicalPayloadAndClientEventID() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await analyticsRecordKeyboardEvents(count: 1, repository: repository)
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
let rowID = try XCTUnwrap(snapshot.pendingEvents.first?.rowID)
|
||||||
|
let storedOriginalPayload = await repository.debugPayloadBytes(rowID: rowID)
|
||||||
|
let originalPayload = try XCTUnwrap(storedOriginalPayload)
|
||||||
|
let originalEvent = try JSONDecoder().decode(
|
||||||
|
AnalyticsEvent.self,
|
||||||
|
from: originalPayload
|
||||||
|
)
|
||||||
|
let upload = AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/events")!
|
||||||
|
)
|
||||||
|
let leasedFirstBatch = await repository.leaseBatch(
|
||||||
|
ownerID: "owner",
|
||||||
|
configuration: upload
|
||||||
|
)
|
||||||
|
let firstLease = try XCTUnwrap(leasedFirstBatch)
|
||||||
|
|
||||||
|
await repository.scheduleRetry(
|
||||||
|
events: firstLease.events,
|
||||||
|
leaseID: firstLease.leaseID,
|
||||||
|
delay: 10
|
||||||
|
)
|
||||||
|
let storedRetriedPayload = await repository.debugPayloadBytes(rowID: rowID)
|
||||||
|
let retriedPayload = try XCTUnwrap(storedRetriedPayload)
|
||||||
|
XCTAssertEqual(retriedPayload, originalPayload)
|
||||||
|
let retrySnapshot = await repository.debugSnapshot()
|
||||||
|
let retryDiagnostic = try XCTUnwrap(retrySnapshot.pendingEvents.first)
|
||||||
|
XCTAssertEqual(retryDiagnostic.attemptCount, 1)
|
||||||
|
XCTAssertEqual(retryDiagnostic.nextAttemptAt, clock.now().addingTimeInterval(10))
|
||||||
|
|
||||||
|
clock.advance(by: 10)
|
||||||
|
let leasedSecondBatch = await repository.leaseBatch(
|
||||||
|
ownerID: "owner",
|
||||||
|
configuration: upload
|
||||||
|
)
|
||||||
|
let secondLease = try XCTUnwrap(leasedSecondBatch)
|
||||||
|
XCTAssertEqual(secondLease.events.first?.payload, originalPayload)
|
||||||
|
let retriedEvent = try JSONDecoder().decode(
|
||||||
|
AnalyticsEvent.self,
|
||||||
|
from: try XCTUnwrap(secondLease.events.first?.payload)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(retriedEvent.clientEventId, originalEvent.clientEventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConcurrentRepositoriesEnforceGlobalLeaseAndRecoverExpiredEventLease() async throws {
|
||||||
|
let url = try analyticsTemporaryDatabaseURL()
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let firstRepository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
let secondRepository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
|
||||||
|
)
|
||||||
|
await analyticsRecordKeyboardEvents(count: 1, repository: firstRepository)
|
||||||
|
let upload = AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/events")!,
|
||||||
|
globalLeaseDuration: 60,
|
||||||
|
eventLeaseDuration: 60
|
||||||
|
)
|
||||||
|
|
||||||
|
async let first = firstRepository.leaseBatch(
|
||||||
|
ownerID: "first-owner",
|
||||||
|
configuration: upload
|
||||||
|
)
|
||||||
|
async let second = secondRepository.leaseBatch(
|
||||||
|
ownerID: "second-owner",
|
||||||
|
configuration: upload
|
||||||
|
)
|
||||||
|
let simultaneous = await (first, second)
|
||||||
|
XCTAssertEqual([simultaneous.0, simultaneous.1].compactMap { $0 }.count, 1)
|
||||||
|
|
||||||
|
clock.advance(by: 61)
|
||||||
|
let recovered = await secondRepository.leaseBatch(
|
||||||
|
ownerID: "recovery-owner",
|
||||||
|
configuration: upload
|
||||||
|
)
|
||||||
|
XCTAssertEqual(recovered?.events.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrashAfterServerAcceptanceBeforeDeleteReplaysOriginalBytes() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await analyticsRecordKeyboardEvents(count: 1, repository: repository)
|
||||||
|
let configuration = AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/events")!,
|
||||||
|
globalLeaseDuration: 60,
|
||||||
|
eventLeaseDuration: 60
|
||||||
|
)
|
||||||
|
let crashedLease = await repository.leaseBatch(
|
||||||
|
ownerID: "crashed-owner",
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
let acceptedButNotDeleted = try XCTUnwrap(crashedLease)
|
||||||
|
|
||||||
|
clock.advance(by: 61)
|
||||||
|
let network = AnalyticsQueueNetwork([
|
||||||
|
.response(analyticsSuccessResponse(accepted: 0, replayed: 1))
|
||||||
|
])
|
||||||
|
let coordinator = AnalyticsUploadCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
configuration: configuration,
|
||||||
|
network: network,
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 500),
|
||||||
|
random: AnalyticsTestRandomGenerator()
|
||||||
|
)
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let requests = await network.requests()
|
||||||
|
XCTAssertEqual(requests.single?.body, acceptedButNotDeleted.body)
|
||||||
|
let completedSnapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertTrue(completedSnapshot.pendingEvents.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBatchHonorsFiftyEventAndDynamicBodyByteLimits() async throws {
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: AnalyticsTestWallClock(),
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await analyticsRecordKeyboardEvents(count: 55, repository: repository)
|
||||||
|
let defaultUpload = AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/events")!,
|
||||||
|
maximumBatchCount: 100,
|
||||||
|
maximumBodyBytes: 100_000
|
||||||
|
)
|
||||||
|
let countLease = await repository.leaseBatch(
|
||||||
|
ownerID: "count",
|
||||||
|
configuration: defaultUpload
|
||||||
|
)
|
||||||
|
let countLimited = try XCTUnwrap(countLease)
|
||||||
|
XCTAssertEqual(countLimited.events.count, 50)
|
||||||
|
XCTAssertLessThanOrEqual(countLimited.body.count, 60 * 1_024)
|
||||||
|
|
||||||
|
await repository.releaseEvents(
|
||||||
|
countLimited.events,
|
||||||
|
leaseID: countLimited.leaseID
|
||||||
|
)
|
||||||
|
await repository.releaseGlobalLease(ownerID: "count")
|
||||||
|
|
||||||
|
let byteUpload = AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/events")!,
|
||||||
|
maximumBatchCount: 50,
|
||||||
|
maximumBodyBytes: 1_024
|
||||||
|
)
|
||||||
|
let byteLease = await repository.leaseBatch(
|
||||||
|
ownerID: "bytes",
|
||||||
|
configuration: byteUpload
|
||||||
|
)
|
||||||
|
let byteLimited = try XCTUnwrap(byteLease)
|
||||||
|
XCTAssertFalse(byteLimited.events.isEmpty)
|
||||||
|
XCTAssertLessThan(byteLimited.events.count, 50)
|
||||||
|
XCTAssertLessThanOrEqual(byteLimited.body.count, 1_024)
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
let selectedIDs = Set(byteLimited.events.map(\.rowID))
|
||||||
|
let nextDiagnostic = try XCTUnwrap(
|
||||||
|
snapshot.pendingEvents.first { !selectedIDs.contains($0.rowID) }
|
||||||
|
)
|
||||||
|
let storedNextPayload = await repository.debugPayloadBytes(
|
||||||
|
rowID: nextDiagnostic.rowID
|
||||||
|
)
|
||||||
|
let nextPayload = try XCTUnwrap(storedNextPayload)
|
||||||
|
XCTAssertGreaterThan(byteLimited.body.count + 1 + nextPayload.count, 1_024)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetentionExpiresAfterThirtyFourDaysAndToleratesClockRollback() async throws {
|
||||||
|
let retention = 34 * 24 * 60 * 60.0
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL(),
|
||||||
|
eventRetention: retention
|
||||||
|
),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await repository.record(
|
||||||
|
eventType: .keyboardActivated,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
|
||||||
|
clock.advance(by: retention)
|
||||||
|
await repository.record(
|
||||||
|
eventType: .purchaseViewed,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
let boundarySnapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertEqual(boundarySnapshot.pendingEvents.count, 2)
|
||||||
|
|
||||||
|
clock.advance(by: 0.001)
|
||||||
|
await repository.record(
|
||||||
|
eventType: .purchaseStarted,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
var events = try await analyticsDecodePendingEvents(repository: repository)
|
||||||
|
XCTAssertEqual(Set(events.map(\.eventType)), [.purchaseViewed, .purchaseStarted])
|
||||||
|
|
||||||
|
let future = clock.now().addingTimeInterval(24 * 60 * 60)
|
||||||
|
clock.set(future)
|
||||||
|
await repository.record(
|
||||||
|
eventType: .referralShared,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
clock.set(future.addingTimeInterval(-7 * 24 * 60 * 60))
|
||||||
|
let upload = AnalyticsUploadConfiguration(
|
||||||
|
endpoint: URL(string: "https://analytics.test/events")!
|
||||||
|
)
|
||||||
|
let batch = await repository.leaseBatch(ownerID: "rollback", configuration: upload)
|
||||||
|
events = try batch?.events.map {
|
||||||
|
try JSONDecoder().decode(AnalyticsEvent.self, from: $0.payload)
|
||||||
|
} ?? []
|
||||||
|
XCTAssertTrue(events.contains(where: { $0.eventType == .referralShared }))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapacityEvictsSessionAndKeyboardBeforeHighValueEvents() async throws {
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL(),
|
||||||
|
maximumEventCount: 2
|
||||||
|
),
|
||||||
|
clock: AnalyticsTestWallClock(),
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
||||||
|
)
|
||||||
|
await repository.prepare(
|
||||||
|
using: analyticsTestContext,
|
||||||
|
firstOpenAcquisitionChannel: .unknown
|
||||||
|
)
|
||||||
|
await repository.recordSessionIfNeeded(context: analyticsTestContext)
|
||||||
|
await repository.record(
|
||||||
|
eventType: .keyboardActivated,
|
||||||
|
context: analyticsTestContext
|
||||||
|
)
|
||||||
|
var eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType)
|
||||||
|
XCTAssertEqual(Set(eventTypes), [.firstOpen, .keyboardActivated])
|
||||||
|
|
||||||
|
await repository.record(
|
||||||
|
eventType: .aiFeatureSucceeded,
|
||||||
|
context: analyticsTestContext,
|
||||||
|
dimensions: AnalyticsEventDimensions(
|
||||||
|
feature: .polish,
|
||||||
|
executionMode: .managed,
|
||||||
|
durationBucket: .oneToThreeSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType)
|
||||||
|
XCTAssertEqual(Set(eventTypes), [.firstOpen, .aiFeatureSucceeded])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
var single: Element? {
|
||||||
|
count == 1 ? first : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// AnalyticsTestSupport.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
//
|
||||||
|
// Deterministic analytics clocks, identifiers, transport doubles, and fixtures.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
let analyticsTestContext = AnalyticsBootstrapContext(
|
||||||
|
surface: .app,
|
||||||
|
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0")
|
||||||
|
)
|
||||||
|
|
||||||
|
func analyticsTestUUID(_ value: Int) -> UUID {
|
||||||
|
UUID(uuidString: String(format: "00000000-0000-0000-0000-%012x", value))!
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsTemporaryDatabaseURL() throws -> URL {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("OSGKeyboardAnalyticsTests", isDirectory: true)
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
return directory.appendingPathComponent("analytics.sqlite3")
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AnalyticsTestWallClock: AnalyticsWallClock, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value: Date
|
||||||
|
|
||||||
|
init(_ value: Date = Date(timeIntervalSince1970: 1_700_000_000)) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() -> Date {
|
||||||
|
lock.withLock { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: Date) {
|
||||||
|
lock.withLock { self.value = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance(by interval: TimeInterval) {
|
||||||
|
lock.withLock { value = value.addingTimeInterval(interval) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AnalyticsTestMonotonicClock: AnalyticsMonotonicClock, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value: UInt64
|
||||||
|
|
||||||
|
init(_ value: UInt64 = 0) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func nowNanoseconds() -> UInt64 {
|
||||||
|
lock.withLock { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: UInt64) {
|
||||||
|
lock.withLock { self.value = value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AnalyticsTestUUIDGenerator: AnalyticsUUIDGenerating, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var nextValue: Int
|
||||||
|
|
||||||
|
init(startingAt value: Int = 1) {
|
||||||
|
nextValue = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUUID() -> UUID {
|
||||||
|
lock.withLock {
|
||||||
|
defer { nextValue += 1 }
|
||||||
|
return analyticsTestUUID(nextValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AnalyticsTestRandomGenerator: AnalyticsRandomGenerating, @unchecked Sendable {
|
||||||
|
enum Value {
|
||||||
|
case zero
|
||||||
|
case upperBound
|
||||||
|
case fixed(UInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let value: Value
|
||||||
|
|
||||||
|
init(_ value: Value = .zero) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func next(upperBound: UInt64) -> UInt64 {
|
||||||
|
switch value {
|
||||||
|
case .zero:
|
||||||
|
return 0
|
||||||
|
case .upperBound:
|
||||||
|
return upperBound
|
||||||
|
case .fixed(let fixed):
|
||||||
|
return min(fixed, upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AnalyticsQueueNetwork: AnalyticsNetworking {
|
||||||
|
enum Outcome: Sendable {
|
||||||
|
case response(AnalyticsHTTPResponse)
|
||||||
|
case urlError(URLError.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outcomes: [Outcome]
|
||||||
|
private var capturedRequests: [AnalyticsHTTPRequest] = []
|
||||||
|
|
||||||
|
init(_ outcomes: [Outcome]) {
|
||||||
|
self.outcomes = outcomes
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
|
||||||
|
capturedRequests.append(request)
|
||||||
|
guard !outcomes.isEmpty else {
|
||||||
|
return AnalyticsHTTPResponse(statusCode: 500, headers: [:], body: Data())
|
||||||
|
}
|
||||||
|
switch outcomes.removeFirst() {
|
||||||
|
case .response(let response):
|
||||||
|
return response
|
||||||
|
case .urlError(let code):
|
||||||
|
throw URLError(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requests() -> [AnalyticsHTTPRequest] {
|
||||||
|
capturedRequests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AnalyticsPoisonNetwork: AnalyticsNetworking {
|
||||||
|
private let statusCode: Int
|
||||||
|
private let poisonID: UUID
|
||||||
|
private var capturedRequests: [AnalyticsHTTPRequest] = []
|
||||||
|
|
||||||
|
init(statusCode: Int, poisonID: UUID) {
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.poisonID = poisonID
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
|
||||||
|
capturedRequests.append(request)
|
||||||
|
let upload = try JSONDecoder().decode(AnalyticsUploadRequest.self, from: request.body)
|
||||||
|
if upload.events.contains(where: { $0.clientEventId == poisonID }) {
|
||||||
|
return AnalyticsHTTPResponse(statusCode: statusCode, headers: [:], body: Data())
|
||||||
|
}
|
||||||
|
return analyticsSuccessResponse(accepted: upload.events.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requests() -> [AnalyticsHTTPRequest] {
|
||||||
|
capturedRequests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AnalyticsTestBearerProvider: AnalyticsBearerProviding {
|
||||||
|
private let initialToken: String?
|
||||||
|
private let refreshedToken: String?
|
||||||
|
private var refreshInputs: [String?] = []
|
||||||
|
|
||||||
|
init(initialToken: String?, refreshedToken: String?) {
|
||||||
|
self.initialToken = initialToken
|
||||||
|
self.refreshedToken = refreshedToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken() async throws -> String? {
|
||||||
|
initialToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshBearerToken(
|
||||||
|
afterUnauthorizedAccessToken failedToken: String?
|
||||||
|
) async throws -> String? {
|
||||||
|
refreshInputs.append(failedToken)
|
||||||
|
return refreshedToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordedRefreshInputs() -> [String?] {
|
||||||
|
refreshInputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsSuccessResponse(
|
||||||
|
accepted: Int,
|
||||||
|
replayed: Int = 0
|
||||||
|
) -> AnalyticsHTTPResponse {
|
||||||
|
AnalyticsHTTPResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
headers: [:],
|
||||||
|
body: Data(#"{"accepted":\#(accepted),"replayed":\#(replayed)}"#.utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsHTTPResponse(
|
||||||
|
statusCode: Int,
|
||||||
|
headers: [String: String] = [:],
|
||||||
|
body: Data = Data()
|
||||||
|
) -> AnalyticsHTTPResponse {
|
||||||
|
AnalyticsHTTPResponse(statusCode: statusCode, headers: headers, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsRecordKeyboardEvents(
|
||||||
|
count: Int,
|
||||||
|
repository: AnalyticsRepository,
|
||||||
|
context: AnalyticsBootstrapContext = analyticsTestContext
|
||||||
|
) async {
|
||||||
|
for _ in 0..<count {
|
||||||
|
await repository.record(
|
||||||
|
eventType: .keyboardActivated,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsPendingPayloads(
|
||||||
|
repository: AnalyticsRepository
|
||||||
|
) async throws -> [Data] {
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
var payloads: [Data] = []
|
||||||
|
for diagnostic in snapshot.pendingEvents {
|
||||||
|
let payload = await repository.debugPayloadBytes(rowID: diagnostic.rowID)
|
||||||
|
payloads.append(try XCTUnwrap(payload))
|
||||||
|
}
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsDecodePendingEvents(
|
||||||
|
repository: AnalyticsRepository
|
||||||
|
) async throws -> [AnalyticsEvent] {
|
||||||
|
let payloads = try await analyticsPendingPayloads(repository: repository)
|
||||||
|
return try payloads.map { try JSONDecoder().decode(AnalyticsEvent.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsRequestBody(payloads: [Data]) -> Data {
|
||||||
|
var body = Data(#"{"events":["#.utf8)
|
||||||
|
for index in payloads.indices {
|
||||||
|
if index > 0 {
|
||||||
|
body.append(UInt8(ascii: ","))
|
||||||
|
}
|
||||||
|
body.append(payloads[index])
|
||||||
|
}
|
||||||
|
body.append(Data("]}".utf8))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsWaitForPendingEventCount(
|
||||||
|
_ count: Int,
|
||||||
|
repository: AnalyticsRepository,
|
||||||
|
timeoutIterations: Int = 200
|
||||||
|
) async -> AnalyticsRepositoryDebugSnapshot {
|
||||||
|
for _ in 0..<timeoutIterations {
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
if snapshot.pendingEvents.count >= count {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
XCTFail("Timed out waiting for \(count) analytics events")
|
||||||
|
return await repository.debugSnapshot()
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// AnalyticsUploadCoordinatorTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
//
|
||||||
|
// HTTP outcome, authentication, retry, and poison-event isolation coverage.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class AnalyticsUploadCoordinatorTests: XCTestCase {
|
||||||
|
private let endpoint = URL(string: "https://analytics.test/v1/events")!
|
||||||
|
|
||||||
|
func testExactAcceptedAndReplayedCountDeletesUsingOriginalPayloadBytes() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(clock: clock, eventCount: 2)
|
||||||
|
let payloads = try await analyticsPendingPayloads(repository: repository)
|
||||||
|
let expectedBody = analyticsRequestBody(payloads: payloads)
|
||||||
|
let network = AnalyticsQueueNetwork([
|
||||||
|
.response(analyticsSuccessResponse(accepted: 1, replayed: 1))
|
||||||
|
])
|
||||||
|
let coordinator = makeCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
network: network,
|
||||||
|
clock: clock
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||||
|
let requests = await network.requests()
|
||||||
|
let request = try XCTUnwrap(requests.first)
|
||||||
|
XCTAssertEqual(requests.count, 1)
|
||||||
|
XCTAssertEqual(request.body, expectedBody)
|
||||||
|
XCTAssertEqual(request.url, endpoint)
|
||||||
|
XCTAssertEqual(request.headers["Content-Type"], "application/json")
|
||||||
|
XCTAssertNil(request.headers["Authorization"])
|
||||||
|
let topLevel = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(Set(topLevel.keys), ["events"])
|
||||||
|
XCTAssertEqual((topLevel["events"] as? [Any])?.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCountMismatchRetriesWithoutMutatingPayloadOrEventID() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(clock: clock, eventCount: 2)
|
||||||
|
let originalPayloads = try await analyticsPendingPayloads(repository: repository)
|
||||||
|
let originalIDs = try originalPayloads.map {
|
||||||
|
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
|
||||||
|
}
|
||||||
|
let network = AnalyticsQueueNetwork([
|
||||||
|
.response(analyticsSuccessResponse(accepted: 1, replayed: 0))
|
||||||
|
])
|
||||||
|
let coordinator = makeCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
network: network,
|
||||||
|
clock: clock,
|
||||||
|
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.pendingEvents.map(\.attemptCount), [1, 1])
|
||||||
|
XCTAssertEqual(
|
||||||
|
snapshot.pendingEvents.map(\.nextAttemptAt),
|
||||||
|
[clock.now().addingTimeInterval(1), clock.now().addingTimeInterval(1)]
|
||||||
|
)
|
||||||
|
let retriedPayloads = try await analyticsPendingPayloads(repository: repository)
|
||||||
|
XCTAssertEqual(retriedPayloads, originalPayloads)
|
||||||
|
let retriedIDs = try retriedPayloads.map {
|
||||||
|
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
|
||||||
|
}
|
||||||
|
XCTAssertEqual(retriedIDs, originalIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNetwork408429And5xxScheduleBoundedBackoff() async throws {
|
||||||
|
let cases: [(AnalyticsQueueNetwork.Outcome, TimeInterval)] = [
|
||||||
|
(.urlError(.notConnectedToInternet), 1),
|
||||||
|
(.response(analyticsHTTPResponse(statusCode: 408)), 1),
|
||||||
|
(
|
||||||
|
.response(
|
||||||
|
analyticsHTTPResponse(
|
||||||
|
statusCode: 429,
|
||||||
|
headers: ["rEtRy-AfTeR": "120"]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
120
|
||||||
|
),
|
||||||
|
(.response(analyticsHTTPResponse(statusCode: 503)), 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (index, testCase) in cases.enumerated() {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(
|
||||||
|
clock: clock,
|
||||||
|
eventCount: 1,
|
||||||
|
uuidStart: 100 * (index + 1)
|
||||||
|
)
|
||||||
|
let originalPayloads = try await analyticsPendingPayloads(
|
||||||
|
repository: repository
|
||||||
|
)
|
||||||
|
let originalPayload = try XCTUnwrap(originalPayloads.first)
|
||||||
|
let network = AnalyticsQueueNetwork([testCase.0])
|
||||||
|
let coordinator = makeCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
network: network,
|
||||||
|
clock: clock,
|
||||||
|
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
let diagnostic = try XCTUnwrap(snapshot.pendingEvents.first)
|
||||||
|
XCTAssertEqual(diagnostic.attemptCount, 1, "case \(index)")
|
||||||
|
XCTAssertEqual(
|
||||||
|
diagnostic.nextAttemptAt,
|
||||||
|
clock.now().addingTimeInterval(testCase.1),
|
||||||
|
"case \(index)"
|
||||||
|
)
|
||||||
|
let storedPayload = await repository.debugPayloadBytes(rowID: diagnostic.rowID)
|
||||||
|
XCTAssertEqual(storedPayload, originalPayload, "case \(index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnauthorizedRefreshesOnceAndRetriesWithNewAuthorization() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(clock: clock, eventCount: 1)
|
||||||
|
let network = AnalyticsQueueNetwork([
|
||||||
|
.response(analyticsHTTPResponse(statusCode: 401)),
|
||||||
|
.response(analyticsSuccessResponse(accepted: 1))
|
||||||
|
])
|
||||||
|
let bearer = AnalyticsTestBearerProvider(
|
||||||
|
initialToken: "old-token",
|
||||||
|
refreshedToken: "new-token"
|
||||||
|
)
|
||||||
|
let coordinator = AnalyticsUploadCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
configuration: uploadConfiguration(),
|
||||||
|
network: network,
|
||||||
|
bearerProvider: bearer,
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||||
|
random: AnalyticsTestRandomGenerator()
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let requests = await network.requests()
|
||||||
|
XCTAssertEqual(requests.count, 2)
|
||||||
|
XCTAssertEqual(requests[0].headers["Authorization"], "Bearer old-token")
|
||||||
|
XCTAssertEqual(requests[1].headers["Authorization"], "Bearer new-token")
|
||||||
|
XCTAssertEqual(requests[0].body, requests[1].body)
|
||||||
|
let refreshInputs = await bearer.recordedRefreshInputs()
|
||||||
|
XCTAssertEqual(refreshInputs.count, 1)
|
||||||
|
XCTAssertEqual(refreshInputs[0], "old-token")
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSecondUnauthorizedDoesNotRefreshAgainAndSchedulesLongRetry() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(clock: clock, eventCount: 1)
|
||||||
|
let network = AnalyticsQueueNetwork([
|
||||||
|
.response(analyticsHTTPResponse(statusCode: 401)),
|
||||||
|
.response(analyticsHTTPResponse(statusCode: 401))
|
||||||
|
])
|
||||||
|
let bearer = AnalyticsTestBearerProvider(
|
||||||
|
initialToken: "old-token",
|
||||||
|
refreshedToken: "new-token"
|
||||||
|
)
|
||||||
|
let configuration = uploadConfiguration(maximumBackoff: 600)
|
||||||
|
let coordinator = AnalyticsUploadCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
configuration: configuration,
|
||||||
|
network: network,
|
||||||
|
bearerProvider: bearer,
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||||
|
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let refreshInputs = await bearer.recordedRefreshInputs()
|
||||||
|
XCTAssertEqual(refreshInputs.count, 1)
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.pendingEvents.first?.attemptCount, 1)
|
||||||
|
XCTAssertEqual(
|
||||||
|
snapshot.pendingEvents.first?.nextAttemptAt,
|
||||||
|
clock.now().addingTimeInterval(600)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidationStatusesRecursivelyIsolateOnlyPoisonEvent() async throws {
|
||||||
|
for statusCode in [400, 409, 422] {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(
|
||||||
|
clock: clock,
|
||||||
|
eventCount: 4,
|
||||||
|
uuidStart: statusCode
|
||||||
|
)
|
||||||
|
let events = try await analyticsDecodePendingEvents(repository: repository)
|
||||||
|
let poisonID = try XCTUnwrap(events.dropFirst().first?.clientEventId)
|
||||||
|
let network = AnalyticsPoisonNetwork(
|
||||||
|
statusCode: statusCode,
|
||||||
|
poisonID: poisonID
|
||||||
|
)
|
||||||
|
let coordinator = makeCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
network: network,
|
||||||
|
clock: clock
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertTrue(snapshot.pendingEvents.isEmpty, "HTTP \(statusCode)")
|
||||||
|
XCTAssertEqual(snapshot.quarantinedEvents.count, 1, "HTTP \(statusCode)")
|
||||||
|
XCTAssertEqual(
|
||||||
|
snapshot.quarantinedEvents.first?.reason,
|
||||||
|
"http\(statusCode)"
|
||||||
|
)
|
||||||
|
let quarantinedRowID = try XCTUnwrap(
|
||||||
|
snapshot.quarantinedEvents.first?.rowID
|
||||||
|
)
|
||||||
|
let quarantinedPayload = await repository.debugPayloadBytes(
|
||||||
|
rowID: quarantinedRowID,
|
||||||
|
quarantined: true
|
||||||
|
)
|
||||||
|
let quarantinedEvent = try JSONDecoder().decode(
|
||||||
|
AnalyticsEvent.self,
|
||||||
|
from: try XCTUnwrap(quarantinedPayload)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(quarantinedEvent.clientEventId, poisonID)
|
||||||
|
let requests = await network.requests()
|
||||||
|
XCTAssertGreaterThan(requests.count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOtherPermanent4xxQuarantinesWholeBatchWithoutRetry() async throws {
|
||||||
|
let clock = AnalyticsTestWallClock()
|
||||||
|
let repository = try await makeRepository(clock: clock, eventCount: 3)
|
||||||
|
let network = AnalyticsQueueNetwork([
|
||||||
|
.response(analyticsHTTPResponse(statusCode: 403))
|
||||||
|
])
|
||||||
|
let coordinator = makeCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
network: network,
|
||||||
|
clock: clock
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.uploadAvailableEvents()
|
||||||
|
|
||||||
|
let snapshot = await repository.debugSnapshot()
|
||||||
|
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||||
|
XCTAssertEqual(snapshot.quarantinedEvents.count, 3)
|
||||||
|
XCTAssertEqual(Set(snapshot.quarantinedEvents.map(\.reason)), ["http403"])
|
||||||
|
let requests = await network.requests()
|
||||||
|
XCTAssertEqual(requests.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRepository(
|
||||||
|
clock: AnalyticsTestWallClock,
|
||||||
|
eventCount: Int,
|
||||||
|
uuidStart: Int = 1
|
||||||
|
) async throws -> AnalyticsRepository {
|
||||||
|
let repository = AnalyticsRepository(
|
||||||
|
configuration: AnalyticsRepositoryConfiguration(
|
||||||
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||||
|
),
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart)
|
||||||
|
)
|
||||||
|
await analyticsRecordKeyboardEvents(
|
||||||
|
count: eventCount,
|
||||||
|
repository: repository
|
||||||
|
)
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadConfiguration(
|
||||||
|
maximumBackoff: TimeInterval = 600
|
||||||
|
) -> AnalyticsUploadConfiguration {
|
||||||
|
AnalyticsUploadConfiguration(
|
||||||
|
endpoint: endpoint,
|
||||||
|
maximumBackoff: maximumBackoff
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCoordinator(
|
||||||
|
repository: AnalyticsRepository,
|
||||||
|
network: some AnalyticsNetworking,
|
||||||
|
clock: AnalyticsTestWallClock,
|
||||||
|
random: AnalyticsTestRandomGenerator = AnalyticsTestRandomGenerator()
|
||||||
|
) -> AnalyticsUploadCoordinator {
|
||||||
|
AnalyticsUploadCoordinator(
|
||||||
|
repository: repository,
|
||||||
|
configuration: uploadConfiguration(),
|
||||||
|
network: network,
|
||||||
|
clock: clock,
|
||||||
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||||
|
random: random
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,50 @@ final class AppGroupOnboardingStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(store3.onboardingPage, 0)
|
XCTAssertEqual(store3.onboardingPage, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOnboardingPracticeWindowExpires() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
KeyboardSetupBridge.setOnboardingPracticeActive(
|
||||||
|
true,
|
||||||
|
duration: 30,
|
||||||
|
defaults: defaults,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
KeyboardSetupBridge.onboardingPracticeIsActive(
|
||||||
|
defaults: defaults,
|
||||||
|
now: now.addingTimeInterval(29)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
KeyboardSetupBridge.onboardingPracticeIsActive(
|
||||||
|
defaults: defaults,
|
||||||
|
now: now.addingTimeInterval(31)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisablingOnboardingPracticeClearsWindow() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
KeyboardSetupBridge.setOnboardingPracticeActive(
|
||||||
|
true,
|
||||||
|
defaults: defaults,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
KeyboardSetupBridge.setOnboardingPracticeActive(
|
||||||
|
false,
|
||||||
|
defaults: defaults,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(
|
||||||
|
KeyboardSetupBridge.onboardingPracticeIsActive(
|
||||||
|
defaults: defaults,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - App context detection round-trip
|
// MARK: - App context detection round-trip
|
||||||
|
|
||||||
func testDetectedAppContextRoundTrip() throws {
|
func testDetectedAppContextRoundTrip() throws {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// FlowHomePiPStatusPolicyTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
@testable import OSGKeyboard
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||||
|
|
||||||
|
func testPreparingAndRecoveringExposeAttemptProgress() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
descriptor(for: .preparing(attempt: 1, total: 3)),
|
||||||
|
.progress(
|
||||||
|
localizationKey: "home.flow.preparingProgress",
|
||||||
|
attempt: 1,
|
||||||
|
total: 3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
descriptor(for: .recovering(attempt: 2, total: 3)),
|
||||||
|
.progress(
|
||||||
|
localizationKey: "home.flow.recoveringProgress",
|
||||||
|
attempt: 2,
|
||||||
|
total: 3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTakeoverAndFailureUseActionableStatuses() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
descriptor(for: .waitingForForeground),
|
||||||
|
.text(localizationKey: "home.flow.waitingForForeground")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
descriptor(for: .failed(.systemRejected)),
|
||||||
|
.text(localizationKey: "home.flow.recoveryFailed")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testActiveSessionDoesNotClaimReadyWithoutReadyContract() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowHomePiPStatusPolicy.descriptor(
|
||||||
|
lifecycle: .active,
|
||||||
|
isStarting: false,
|
||||||
|
isRecording: false,
|
||||||
|
isProcessing: false,
|
||||||
|
isActive: true,
|
||||||
|
isHostReady: false
|
||||||
|
),
|
||||||
|
.text(localizationKey: "home.flow.notReady")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowHomePiPStatusPolicy.descriptor(
|
||||||
|
lifecycle: .active,
|
||||||
|
isStarting: false,
|
||||||
|
isRecording: false,
|
||||||
|
isProcessing: false,
|
||||||
|
isActive: true,
|
||||||
|
isHostReady: true
|
||||||
|
),
|
||||||
|
.text(localizationKey: "home.flow.label")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetryAppearsOnlyForRecoverableFailureWithPermissions() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
FlowHomePiPStatusPolicy.canRetry(
|
||||||
|
lifecycle: .failed(.timedOut),
|
||||||
|
needsPermissionSetup: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowHomePiPStatusPolicy.canRetry(
|
||||||
|
lifecycle: .failed(.timedOut),
|
||||||
|
needsPermissionSetup: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowHomePiPStatusPolicy.canRetry(
|
||||||
|
lifecycle: .recovering(attempt: 1, total: 3),
|
||||||
|
needsPermissionSetup: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func descriptor(
|
||||||
|
for lifecycle: FlowPiPLifecycleState
|
||||||
|
) -> FlowHomePiPStatusDescriptor {
|
||||||
|
FlowHomePiPStatusPolicy.descriptor(
|
||||||
|
lifecycle: lifecycle,
|
||||||
|
isStarting: false,
|
||||||
|
isRecording: false,
|
||||||
|
isProcessing: false,
|
||||||
|
isActive: false,
|
||||||
|
isHostReady: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// FlowPiPRecoveryPolicyTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
@testable import OSGKeyboard
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class FlowPiPRecoveryPolicyTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Reconciliation
|
||||||
|
|
||||||
|
func testNoSessionIntentNeverStartsRecovery() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.reconciliationDecision(
|
||||||
|
wantsActiveSession: false,
|
||||||
|
isAppForeground: true,
|
||||||
|
isPictureInPictureActive: false
|
||||||
|
),
|
||||||
|
.noSessionIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testActivePiPIsIdempotent() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.reconciliationDecision(
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: true,
|
||||||
|
isPictureInPictureActive: true
|
||||||
|
),
|
||||||
|
.alreadyActive
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTakeoverWaitsWhileAppIsInBackground() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.reconciliationDecision(
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: false,
|
||||||
|
isPictureInPictureActive: false
|
||||||
|
),
|
||||||
|
.waitForForeground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReturningToForegroundStartsRecovery() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.reconciliationDecision(
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: true,
|
||||||
|
isPictureInPictureActive: false
|
||||||
|
),
|
||||||
|
.startRecovery
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bounded retry
|
||||||
|
|
||||||
|
func testRetryPolicyUsesThreeIncreasingBoundedAttempts() {
|
||||||
|
XCTAssertEqual(FlowPiPRecoveryPolicy.maxAttempts, 3)
|
||||||
|
XCTAssertEqual(
|
||||||
|
(1...FlowPiPRecoveryPolicy.maxAttempts).map {
|
||||||
|
FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: $0)
|
||||||
|
},
|
||||||
|
[0, 0.25, 0.6]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(FlowPiPRecoveryPolicy.totalBudget, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetryTimeoutsNeverExceedRemainingBudget() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.hostTimeout(attempt: 1, remainingBudget: 0.4),
|
||||||
|
0.4
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.hostTimeout(attempt: 2, remainingBudget: 4),
|
||||||
|
0.25
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.3),
|
||||||
|
0.3
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 4),
|
||||||
|
1.4
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.01),
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Late completion safety
|
||||||
|
|
||||||
|
func testCurrentForegroundOperationCanContinue() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: 7,
|
||||||
|
currentOperation: 7,
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: true,
|
||||||
|
taskIsCancelled: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOldGenerationCannotOverwriteNewRecovery() {
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: 6,
|
||||||
|
currentOperation: 7,
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: true,
|
||||||
|
taskIsCancelled: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExplicitEndPreventsLateRecovery() {
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: 7,
|
||||||
|
currentOperation: 8,
|
||||||
|
wantsActiveSession: false,
|
||||||
|
isAppForeground: true,
|
||||||
|
taskIsCancelled: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBackgroundAndCancellationBothStopRecovery() {
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: 7,
|
||||||
|
currentOperation: 7,
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: false,
|
||||||
|
taskIsCancelled: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowPiPRecoveryPolicy.canContinue(
|
||||||
|
operation: 7,
|
||||||
|
currentOperation: 7,
|
||||||
|
wantsActiveSession: true,
|
||||||
|
isAppForeground: true,
|
||||||
|
taskIsCancelled: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1342,7 +1342,7 @@ final class FlowSessionBridgeTests: XCTestCase {
|
|||||||
startDeadlineAt: 1_700_000_008.25,
|
startDeadlineAt: 1_700_000_008.25,
|
||||||
processingDeadlineAt: 1_700_000_045.25
|
processingDeadlineAt: 1_700_000_045.25
|
||||||
)
|
)
|
||||||
let expected = #"{"action":"startRecording","aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000000.25,"editSourceText":"draft","fieldContext":{"followingText":"after","isContextAvailable":true,"isEmptyField":false,"isSecureEntry":false,"keyboardType":"default","precedingText":"before","returnKeyType":"send"},"localeId":"en-US","processingDeadlineAt":1700000045.25,"protocolVersion":6,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","sourceHistoryEntryID":"22222222-3333-4444-5555-666666666666","sourceHistoryEntryRevision":7,"startDeadlineAt":1700000008.25,"utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"editLastInput"}"#
|
let expected = #"{"action":"startRecording","aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000000.25,"editSourceText":"draft","fieldContext":{"followingText":"after","isContextAvailable":true,"isEmptyField":false,"isSecureEntry":false,"keyboardType":"default","precedingText":"before","returnKeyType":"send"},"localeId":"en-US","processingDeadlineAt":1700000045.25,"protocolVersion":7,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","sourceHistoryEntryID":"22222222-3333-4444-5555-666666666666","sourceHistoryEntryRevision":7,"startDeadlineAt":1700000008.25,"utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"editLastInput"}"#
|
||||||
|
|
||||||
XCTAssertEqual(try sortedJSONString(command), expected)
|
XCTAssertEqual(try sortedJSONString(command), expected)
|
||||||
}
|
}
|
||||||
@@ -1378,7 +1378,7 @@ final class FlowSessionBridgeTests: XCTestCase {
|
|||||||
historyEntryRevision: 9,
|
historyEntryRevision: 9,
|
||||||
aiConversationID: conversationID
|
aiConversationID: conversationID
|
||||||
)
|
)
|
||||||
let expected = #"{"aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000050.5,"errorKind":"asrFailed","fieldFingerprint":"default|send|before|after","historyEntryID":"22222222-3333-4444-5555-666666666666","historyEntryRevision":9,"hostGeneration":"generation-1","protocolVersion":6,"rawText":"raw","revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","status":"final","text":"polished","utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"aiQuestion","warning":"fallback"}"#
|
let expected = #"{"aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000050.5,"errorKind":"asrFailed","fieldFingerprint":"default|send|before|after","historyEntryID":"22222222-3333-4444-5555-666666666666","historyEntryRevision":9,"hostGeneration":"generation-1","protocolVersion":7,"rawText":"raw","revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","status":"final","text":"polished","utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"aiQuestion","warning":"fallback"}"#
|
||||||
|
|
||||||
XCTAssertEqual(try sortedJSONString(result), expected)
|
XCTAssertEqual(try sortedJSONString(result), expected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ iOS / iPadOS 账号中心支持:
|
|||||||
- **击键不上传**:中文候选学习与英文建议偏好保存在本机,密码框不学习
|
- **击键不上传**:中文候选学习与英文建议偏好保存在本机,密码框不学习
|
||||||
- **剪贴板可控**:历史默认关闭、最多 15 条、仅本机保存,不会自行发送给 AI
|
- **剪贴板可控**:历史默认关闭、最多 15 条、仅本机保存,不会自行发送给 AI
|
||||||
- **凭证隔离**:用户 API Key 保存在 Keychain;账号会话令牌只在主 App 私有 Keychain
|
- **凭证隔离**:用户 API Key 保存在 Keychain;账号会话令牌只在主 App 私有 Keychain
|
||||||
- **无广告追踪**:不集成广告、分析或追踪 SDK,不出售个人数据
|
- **无广告追踪**:不集成第三方分析、广告或追踪 SDK;可退出的第一方产品分析不采集输入内容,不出售个人数据
|
||||||
|
|
||||||
完整数据类型、保留策略、账号删除与第三方服务说明见[隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。
|
完整数据类型、保留策略、账号删除与第三方服务说明见[隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
|
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50] [SUITES=cold,bgfg,hold]
|
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50] [SUITES=cold,bgfg,hold,reclaim]
|
||||||
#
|
#
|
||||||
# Suites:
|
# Suites:
|
||||||
# cold — terminate-existing launch (force-quit / cold start)
|
# cold — terminate-existing launch (force-quit / cold start)
|
||||||
# bgfg — open Safari (background) then relaunch OSG (foreground restore)
|
# bgfg — open Safari (background) then relaunch OSG (foreground restore)
|
||||||
# hold — start PiP, wait 20s in session, relaunch to verify still recoverable
|
# hold — start PiP, wait 20s in session, relaunch to verify still recoverable
|
||||||
|
# reclaim — foreground another PiP-capable app, then reopen OSG to reclaim
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@@ -15,9 +16,11 @@ cd "$ROOT"
|
|||||||
|
|
||||||
UDID="${1:-00008130-001C249C0E52001C}"
|
UDID="${1:-00008130-001C249C0E52001C}"
|
||||||
COUNT="${2:-50}"
|
COUNT="${2:-50}"
|
||||||
SUITES="${3:-cold,bgfg,hold}"
|
SUITES="${3:-cold,bgfg,hold,reclaim}"
|
||||||
BUNDLE="com.osgkeyboard.ios"
|
BUNDLE="com.osgkeyboard.ios"
|
||||||
SAFARI="com.apple.mobilesafari"
|
SAFARI="com.apple.mobilesafari"
|
||||||
|
TAKEOVER_BUNDLE="${PIP_TAKEOVER_BUNDLE:-$SAFARI}"
|
||||||
|
TAKEOVER_SETTLE_SECONDS="${PIP_TAKEOVER_SETTLE_SECONDS:-5}"
|
||||||
OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)"
|
OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)"
|
||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
REPORT="$OUT_DIR/report.jsonl"
|
REPORT="$OUT_DIR/report.jsonl"
|
||||||
@@ -48,9 +51,18 @@ console_launch() {
|
|||||||
"$BUNDLE" >"$outfile" 2>&1 || true
|
"$BUNDLE" >"$outfile" 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Launch without attaching a console. Killing a timed `--console` command also
|
||||||
|
# terminates the app, so preparation phases must use a detached launch.
|
||||||
|
plain_launch() {
|
||||||
|
xcrun devicectl device process launch \
|
||||||
|
--device "$UDID" \
|
||||||
|
--terminate-existing \
|
||||||
|
"$BUNDLE" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
classify_file() {
|
classify_file() {
|
||||||
local f="$1"
|
local f="$1"
|
||||||
local host="unknown" pip="unknown" mic="unknown"
|
local host="unknown" pip="unknown" mic="unknown" recovery="none"
|
||||||
|
|
||||||
if rg -q "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
|
if rg -q "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
|
||||||
host="success"
|
host="success"
|
||||||
@@ -93,12 +105,23 @@ classify_file() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$host|$pip|$mic"
|
if rg -q "pip\.unexpectedStop" "$f" \
|
||||||
|
&& rg -q "startSessionAsync\.ready.*trigger=(foreground|healthCheck|existingSession)" "$f"; then
|
||||||
|
recovery="reclaimed"
|
||||||
|
elif rg -q "startSessionAsync\.ready.*trigger=(foreground|healthCheck|existingSession|manualRetry)" "$f"; then
|
||||||
|
recovery="reconciled"
|
||||||
|
elif rg -q "failed after bounded recovery|pipState=failed" "$f"; then
|
||||||
|
recovery="exhausted"
|
||||||
|
elif rg -q "pipRecovery\.attempt" "$f"; then
|
||||||
|
recovery="attempted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$host|$pip|$mic|$recovery"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_suite() {
|
run_suite() {
|
||||||
local suite="$1"
|
local suite="$1"
|
||||||
local i outfile result host pip mic start_ts elapsed flags
|
local i outfile result host pip mic recovery start_ts elapsed flags
|
||||||
echo "==> Suite: $suite × $COUNT"
|
echo "==> Suite: $suite × $COUNT"
|
||||||
for i in $(seq 1 "$COUNT"); do
|
for i in $(seq 1 "$COUNT"); do
|
||||||
outfile="$OUT_DIR/${suite}-$i.console.log"
|
outfile="$OUT_DIR/${suite}-$i.console.log"
|
||||||
@@ -112,7 +135,8 @@ run_suite() {
|
|||||||
;;
|
;;
|
||||||
bgfg)
|
bgfg)
|
||||||
# Ensure app running with PiP first
|
# Ensure app running with PiP first
|
||||||
console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true
|
plain_launch
|
||||||
|
sleep 7
|
||||||
# Background by opening Safari
|
# Background by opening Safari
|
||||||
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
|
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
|
||||||
sleep 3
|
sleep 3
|
||||||
@@ -120,7 +144,8 @@ run_suite() {
|
|||||||
console_launch "$outfile" 12 ""
|
console_launch "$outfile" 12 ""
|
||||||
;;
|
;;
|
||||||
hold)
|
hold)
|
||||||
console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true
|
plain_launch
|
||||||
|
sleep 7
|
||||||
# Leave session alive ~20s (PiP should keep host)
|
# Leave session alive ~20s (PiP should keep host)
|
||||||
sleep 20
|
sleep 20
|
||||||
# Background briefly then resume
|
# Background briefly then resume
|
||||||
@@ -128,6 +153,15 @@ run_suite() {
|
|||||||
sleep 2
|
sleep 2
|
||||||
console_launch "$outfile" 12 ""
|
console_launch "$outfile" 12 ""
|
||||||
;;
|
;;
|
||||||
|
reclaim)
|
||||||
|
plain_launch
|
||||||
|
sleep 7
|
||||||
|
# The target app must already be configured to enter PiP when launched.
|
||||||
|
# Safari is only a foreground/background fallback unless media is playing.
|
||||||
|
xcrun devicectl device process launch --device "$UDID" "$TAKEOVER_BUNDLE" >/dev/null 2>&1 || true
|
||||||
|
sleep "$TAKEOVER_SETTLE_SECONDS"
|
||||||
|
console_launch "$outfile" 14 ""
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
elapsed=$(( $(date +%s) - start_ts ))
|
elapsed=$(( $(date +%s) - start_ts ))
|
||||||
@@ -135,12 +169,14 @@ run_suite() {
|
|||||||
host="${result%%|*}"
|
host="${result%%|*}"
|
||||||
rest="${result#*|}"
|
rest="${result#*|}"
|
||||||
pip="${rest%%|*}"
|
pip="${rest%%|*}"
|
||||||
mic="${rest##*|}"
|
rest="${rest#*|}"
|
||||||
|
mic="${rest%%|*}"
|
||||||
|
recovery="${rest##*|}"
|
||||||
|
|
||||||
printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","elapsed_s":%d}\n' \
|
printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","recovery":"%s","elapsed_s":%d}\n' \
|
||||||
"$suite" "$i" "$host" "$pip" "$mic" "$elapsed" >>"$REPORT"
|
"$suite" "$i" "$host" "$pip" "$mic" "$recovery" "$elapsed" >>"$REPORT"
|
||||||
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s %2ds\n" \
|
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s recovery=%-10s %2ds\n" \
|
||||||
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$elapsed"
|
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$recovery" "$elapsed"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +184,7 @@ run_suite() {
|
|||||||
IFS=',' read -r -a selected_suites <<<"$SUITES"
|
IFS=',' read -r -a selected_suites <<<"$SUITES"
|
||||||
for selected_suite in "${selected_suites[@]}"; do
|
for selected_suite in "${selected_suites[@]}"; do
|
||||||
case "$selected_suite" in
|
case "$selected_suite" in
|
||||||
cold|bgfg|hold) run_suite "$selected_suite" ;;
|
cold|bgfg|hold|reclaim) run_suite "$selected_suite" ;;
|
||||||
*)
|
*)
|
||||||
echo "error: unknown suite '$selected_suite'" >&2
|
echo "error: unknown suite '$selected_suite'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -165,7 +201,7 @@ by = collections.defaultdict(list)
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
by[r["suite"]].append(r)
|
by[r["suite"]].append(r)
|
||||||
|
|
||||||
selected = [name for name in ("cold", "bgfg", "hold") if by.get(name)]
|
selected = [name for name in ("cold", "bgfg", "hold", "reclaim") if by.get(name)]
|
||||||
lines = [
|
lines = [
|
||||||
"Device PiP / mic keep-alive stress summary",
|
"Device PiP / mic keep-alive stress summary",
|
||||||
f"device=Rocky 15 PM udid={udid} count_per_suite={count}",
|
f"device=Rocky 15 PM udid={udid} count_per_suite={count}",
|
||||||
@@ -178,12 +214,14 @@ for suite in selected:
|
|||||||
host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only"))
|
host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only"))
|
||||||
pip_c = collections.Counter(r["pip"] for r in rs)
|
pip_c = collections.Counter(r["pip"] for r in rs)
|
||||||
mic_c = collections.Counter(r["mic"] for r in rs)
|
mic_c = collections.Counter(r["mic"] for r in rs)
|
||||||
|
recovery_c = collections.Counter(r["recovery"] for r in rs)
|
||||||
pip_ok = pip_c.get("success", 0)
|
pip_ok = pip_c.get("success", 0)
|
||||||
lines += [
|
lines += [
|
||||||
f"[{suite}]",
|
f"[{suite}]",
|
||||||
f" host_ok={host_ok}/{len(rs)} ({host_ok/n*100:.1f}%)",
|
f" host_ok={host_ok}/{len(rs)} ({host_ok/n*100:.1f}%)",
|
||||||
f" pip_success={pip_ok}/{len(rs)} ({pip_ok/n*100:.1f}%) breakdown={dict(pip_c)}",
|
f" pip_success={pip_ok}/{len(rs)} ({pip_ok/n*100:.1f}%) breakdown={dict(pip_c)}",
|
||||||
f" mic={dict(mic_c)}",
|
f" mic={dict(mic_c)}",
|
||||||
|
f" recovery={dict(recovery_c)}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
lines += [
|
lines += [
|
||||||
@@ -192,6 +230,7 @@ lines += [
|
|||||||
" - cold uses --terminate-existing (force-quit recovery).",
|
" - cold uses --terminate-existing (force-quit recovery).",
|
||||||
" - bgfg backgrounds via Safari then resumes.",
|
" - bgfg backgrounds via Safari then resumes.",
|
||||||
" - hold keeps session ~20s then Safari background + resume.",
|
" - hold keeps session ~20s then Safari background + resume.",
|
||||||
|
" - reclaim launches PIP_TAKEOVER_BUNDLE, then verifies OSG foreground reconciliation.",
|
||||||
" - Keyboard-extension mic tap is not automated.",
|
" - Keyboard-extension mic tap is not automated.",
|
||||||
]
|
]
|
||||||
text = "\n".join(lines) + "\n"
|
text = "\n".join(lines) + "\n"
|
||||||
|
|||||||
@@ -40,6 +40,17 @@
|
|||||||
"OSGKeyboardExtTests/ManagedGatewayTests"
|
"OSGKeyboardExtTests/ManagedGatewayTests"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"analytics": {
|
||||||
|
"description": "Privacy-safe analytics schema, durable queue, uploader, identity lifecycle, and anonymous extension runtime",
|
||||||
|
"platform": "ios",
|
||||||
|
"tests": [
|
||||||
|
"OSGKeyboardTests/AnalyticsModelTests",
|
||||||
|
"OSGKeyboardTests/AnalyticsRepositoryTests",
|
||||||
|
"OSGKeyboardTests/AnalyticsUploadCoordinatorTests",
|
||||||
|
"OSGKeyboardTests/AnalyticsAIOperationTests",
|
||||||
|
"OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests"
|
||||||
|
]
|
||||||
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"description": "iCloud KVS merge/sync for settings, local-only clipboard consent, dictionary, speech history, usage stats",
|
"description": "iCloud KVS merge/sync for settings, local-only clipboard consent, dictionary, speech history, usage stats",
|
||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
@@ -123,6 +134,8 @@
|
|||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
"tests": [
|
"tests": [
|
||||||
"OSGKeyboardTests/FlowHandoffPolicyTests",
|
"OSGKeyboardTests/FlowHandoffPolicyTests",
|
||||||
|
"OSGKeyboardTests/FlowHomePiPStatusPolicyTests",
|
||||||
|
"OSGKeyboardTests/FlowPiPRecoveryPolicyTests",
|
||||||
"OSGKeyboardTests/FlowSessionBridgeTests",
|
"OSGKeyboardTests/FlowSessionBridgeTests",
|
||||||
"OSGKeyboardTests/FlowSessionPolicyTests",
|
"OSGKeyboardTests/FlowSessionPolicyTests",
|
||||||
"OSGKeyboardTests/FlowBudgetAndMergeTests",
|
"OSGKeyboardTests/FlowBudgetAndMergeTests",
|
||||||
@@ -207,6 +220,7 @@
|
|||||||
"config",
|
"config",
|
||||||
"account",
|
"account",
|
||||||
"managed_gateway",
|
"managed_gateway",
|
||||||
|
"analytics",
|
||||||
"sync",
|
"sync",
|
||||||
"polish",
|
"polish",
|
||||||
"edit_last_input",
|
"edit_last_input",
|
||||||
@@ -225,6 +239,7 @@
|
|||||||
"config",
|
"config",
|
||||||
"account",
|
"account",
|
||||||
"managed_gateway",
|
"managed_gateway",
|
||||||
|
"analytics",
|
||||||
"sync",
|
"sync",
|
||||||
"polish",
|
"polish",
|
||||||
"edit_last_input",
|
"edit_last_input",
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ TYPING
|
|||||||
|
|
||||||
PRIVACY
|
PRIVACY
|
||||||
|
|
||||||
• No advertising, analytics, or tracking SDKs.
|
• Limited first-party product analytics; no third-party analytics,
|
||||||
|
advertising, tracking SDKs, ATT, or IDFA.
|
||||||
|
• Product analytics never includes keyboard input, audio, transcripts,
|
||||||
|
prompts, model output, credentials, or personal identifiers. It can be
|
||||||
|
disabled in Settings, which deletes queued events.
|
||||||
• Local recognition does not upload audio.
|
• Local recognition does not upload audio.
|
||||||
• User-configured cloud requests go directly to that provider. Managed-credit
|
• User-configured cloud requests go directly to that provider. Managed-credit
|
||||||
requests go through OSGKeyboard's managed gateway to the managed provider.
|
requests go through OSGKeyboard's managed gateway to the managed provider.
|
||||||
@@ -157,6 +161,9 @@ OSGKeyboard is a custom keyboard for iOS/iPadOS 26.
|
|||||||
Clipboard, then return to the keyboard. Secure fields hide the clipboard
|
Clipboard, then return to the keyboard. Secure fields hide the clipboard
|
||||||
entry point. Turning History off preserves saved items; use the separate
|
entry point. Turning History off preserves saved items; use the separate
|
||||||
confirmed clear action to delete them.
|
confirmed clear action to delete them.
|
||||||
|
10. First-party Product Analytics is enabled by default under Settings →
|
||||||
|
About → Privacy. Turning it off deletes queued events. It does not collect
|
||||||
|
keyboard input, audio, transcripts, prompts, model output, or credentials.
|
||||||
|
|
||||||
Privacy policy:
|
Privacy policy:
|
||||||
https://hkgood.github.io/OSGKeyboard/privacy/
|
https://hkgood.github.io/OSGKeyboard/privacy/
|
||||||
@@ -230,9 +237,41 @@ This covers the pseudonymous OSGKeyboard account identifier and scoped
|
|||||||
managed-service grant identifiers. Core use remains available without an
|
managed-service grant identifiers. Core use remains available without an
|
||||||
OSGKeyboard account.
|
OSGKeyboard account.
|
||||||
|
|
||||||
|
### Identifiers → Device ID
|
||||||
|
|
||||||
|
- Collected: Yes
|
||||||
|
- Purpose: Analytics
|
||||||
|
- Linked to the user: Yes
|
||||||
|
- Used for tracking: No
|
||||||
|
|
||||||
|
This is an app-scoped random installation identifier. It rotates when analytics
|
||||||
|
is re-enabled, after account deletion, or when a different account signs in. It
|
||||||
|
is not IDFA and is not used across apps.
|
||||||
|
|
||||||
|
### Usage Data → Product Interaction
|
||||||
|
|
||||||
|
- Collected: Yes
|
||||||
|
- Purpose: Analytics
|
||||||
|
- Linked to the user: Yes
|
||||||
|
- Used for tracking: No
|
||||||
|
|
||||||
|
This covers fixed event names for app and keyboard sessions, purchase-page
|
||||||
|
interactions, and invitation actions. It contains no free-form properties.
|
||||||
|
|
||||||
|
### Usage Data → Other Usage Data
|
||||||
|
|
||||||
|
- Collected: Yes
|
||||||
|
- Purpose: Analytics
|
||||||
|
- Linked to the user: Yes
|
||||||
|
- Used for tracking: No
|
||||||
|
|
||||||
|
This covers fixed AI feature categories, execution modes, outcome categories,
|
||||||
|
and coarse duration buckets. It does not include prompts, transcripts, model
|
||||||
|
output, audio, or keyboard content.
|
||||||
|
|
||||||
### Do not select
|
### Do not select
|
||||||
|
|
||||||
- Advertising, marketing, analytics, product personalization, or tracking
|
- Advertising, marketing, product personalization, or tracking
|
||||||
- Email address, phone number, physical address, location, contacts, photos,
|
- Email address, phone number, physical address, location, contacts, photos,
|
||||||
browsing history, or search history
|
browsing history, or search history
|
||||||
- Usage data or diagnostics stored only locally or in the user's private iCloud
|
- Usage data or diagnostics stored only locally or in the user's private iCloud
|
||||||
@@ -249,6 +288,8 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
|
|||||||
- [ ] Run the release build and test suites on macOS with Xcode 26
|
- [ ] Run the release build and test suites on macOS with Xcode 26
|
||||||
- [ ] Replace screenshots with captures from the submitted build
|
- [ ] Replace screenshots with captures from the submitted build
|
||||||
- [ ] Verify the privacy answers against the submitted provider features
|
- [ ] Verify the privacy answers against the submitted provider features
|
||||||
|
- [ ] In App Store Connect, add Device ID, Product Interaction, and Other
|
||||||
|
Usage Data for Analytics; linked to the user, not used for tracking
|
||||||
- [ ] Confirm `500tks`, `1500tks`, and `3000tks` are approved, consumable,
|
- [ ] Confirm `500tks`, `1500tks`, and `3000tks` are approved, consumable,
|
||||||
and mapped to the server credit catalog
|
and mapped to the server credit catalog
|
||||||
- [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks
|
- [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks
|
||||||
|
|||||||
+62
-169
@@ -6,7 +6,7 @@
|
|||||||
--surface-strong: #e5eaf0;
|
--surface-strong: #e5eaf0;
|
||||||
--ink: #111416;
|
--ink: #111416;
|
||||||
--ink-secondary: #5f676e;
|
--ink-secondary: #5f676e;
|
||||||
--ink-tertiary: #8b949c;
|
--ink-tertiary: #626c74;
|
||||||
--line: rgba(17, 20, 22, 0.1);
|
--line: rgba(17, 20, 22, 0.1);
|
||||||
--line-strong: rgba(17, 20, 22, 0.18);
|
--line-strong: rgba(17, 20, 22, 0.18);
|
||||||
--accent: #2d8a57;
|
--accent: #2d8a57;
|
||||||
@@ -28,7 +28,7 @@ html[data-theme="dark"] {
|
|||||||
--surface-strong: #2b3033;
|
--surface-strong: #2b3033;
|
||||||
--ink: #f2f4f3;
|
--ink: #f2f4f3;
|
||||||
--ink-secondary: #aab0ac;
|
--ink-secondary: #aab0ac;
|
||||||
--ink-tertiary: #737a75;
|
--ink-tertiary: #969e98;
|
||||||
--line: rgba(255, 255, 255, 0.09);
|
--line: rgba(255, 255, 255, 0.09);
|
||||||
--line-strong: rgba(255, 255, 255, 0.16);
|
--line-strong: rgba(255, 255, 255, 0.16);
|
||||||
--accent: #6fbd8a;
|
--accent: #6fbd8a;
|
||||||
@@ -97,6 +97,13 @@ h3 {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-lang="zh"] h1,
|
||||||
|
html[data-lang="zh"] h2,
|
||||||
|
html[data-lang="zh"] h3 {
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -300,27 +307,6 @@ html[data-theme="dark"] #themeToggle::before {
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-tabs > .material-symbols-rounded::before {
|
|
||||||
content: "•••";
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.round-control:first-child .material-symbols-rounded::before {
|
|
||||||
content: "↶";
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.round-control:last-child .material-symbols-rounded::before {
|
|
||||||
content: "↑";
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mic-control .material-symbols-rounded::before {
|
|
||||||
content: "●";
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gesture-grid .material-symbols-rounded,
|
.gesture-grid .material-symbols-rounded,
|
||||||
.skill-actions .material-symbols-rounded {
|
.skill-actions .material-symbols-rounded {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -341,16 +327,6 @@ html[data-theme="dark"] #themeToggle::before {
|
|||||||
font-size: 1.55rem;
|
font-size: 1.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-symbol .material-symbols-rounded::before {
|
|
||||||
content: "⌨";
|
|
||||||
font-size: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ios-symbol .material-symbols-rounded {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outline-action .material-symbols-rounded::before {
|
.outline-action .material-symbols-rounded::before {
|
||||||
content: "⌘";
|
content: "⌘";
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -467,6 +443,14 @@ html[data-lang="zh"] .hero h1 {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .store-badge img {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .store-badge img {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.store-badge:hover,
|
.store-badge:hover,
|
||||||
.store-badge:focus-visible {
|
.store-badge:focus-visible {
|
||||||
opacity: 0.86;
|
opacity: 0.86;
|
||||||
@@ -718,103 +702,37 @@ html[data-theme="dark"] .message-bubble {
|
|||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-demo {
|
.keyboard-capture {
|
||||||
display: flex;
|
position: relative;
|
||||||
|
align-self: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
aspect-ratio: 1.18;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--surface-strong);
|
background: var(--surface-strong);
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-tabs {
|
.keyboard-capture img {
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: 46px repeat(3, auto) 1fr;
|
width: 100%;
|
||||||
gap: 8px;
|
height: 100%;
|
||||||
align-items: center;
|
object-fit: cover;
|
||||||
padding: 16px 18px;
|
object-position: center bottom;
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-logo {
|
.keyboard-capture figcaption {
|
||||||
display: grid;
|
position: absolute;
|
||||||
width: 36px;
|
top: 16px;
|
||||||
height: 36px;
|
left: 16px;
|
||||||
border-radius: 11px;
|
padding: 7px 10px;
|
||||||
background: var(--accent);
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
color: #fff;
|
|
||||||
font-size: 0.66rem;
|
|
||||||
font-weight: 800;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-tab {
|
|
||||||
padding: 6px 9px;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--ink-tertiary);
|
background: rgba(17, 20, 22, 0.68);
|
||||||
font-size: 0.72rem;
|
color: #ffffff;
|
||||||
|
font-size: 0.68rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
.assistant-tab.active {
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-tabs > .material-symbols-rounded {
|
|
||||||
justify-self: end;
|
|
||||||
color: var(--ink-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-keys {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(10, 1fr);
|
|
||||||
gap: 7px;
|
|
||||||
padding: clamp(42px, 6vw, 80px) 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-keys span {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
height: 42px;
|
|
||||||
border-bottom: 2px solid var(--line-strong);
|
|
||||||
color: var(--ink-secondary);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-keys span:nth-child(n + 11) {
|
|
||||||
transform: translateX(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 22px 24px 26px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.round-control,
|
|
||||||
.mic-control {
|
|
||||||
display: grid;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--ink-secondary);
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.round-control:last-child {
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mic-control {
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 26%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gesture-grid {
|
.gesture-grid {
|
||||||
@@ -1417,29 +1335,28 @@ html[data-theme="dark"] .privacy-section {
|
|||||||
|
|
||||||
.platform-symbol {
|
.platform-symbol {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 110px;
|
width: 104px;
|
||||||
height: 150px;
|
height: 104px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 28px;
|
border-radius: 24px;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
color: var(--ink-secondary);
|
color: var(--ink-secondary);
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-symbol .material-symbols-rounded {
|
.platform-symbol svg {
|
||||||
font-size: 42px;
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mac-symbol {
|
.mac-symbol {
|
||||||
width: 116px;
|
border-radius: 24px;
|
||||||
height: 96px;
|
|
||||||
border-radius: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-key {
|
|
||||||
font-size: 2.4rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-stories h3 {
|
.platform-stories h3 {
|
||||||
@@ -1795,10 +1712,6 @@ html[data-lang="zh"] .download-panel h2 {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-demo {
|
|
||||||
min-height: 430px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-copy {
|
.scene-copy {
|
||||||
max-width: 690px;
|
max-width: 690px;
|
||||||
}
|
}
|
||||||
@@ -1926,7 +1839,7 @@ html[data-lang="zh"] .download-panel h2 {
|
|||||||
font-size: clamp(2rem, 9vw, 2.2rem);
|
font-size: clamp(2rem, 9vw, 2.2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 > span {
|
html[data-lang="zh"] .hero h1 > span {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1987,30 +1900,6 @@ html[data-lang="zh"] .download-panel h2 {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-demo {
|
|
||||||
min-height: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-tabs {
|
|
||||||
grid-template-columns: 42px repeat(3, auto) 1fr;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 13px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-tab {
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-keys {
|
|
||||||
gap: 4px;
|
|
||||||
padding: 45px 10px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-keys span {
|
|
||||||
height: 36px;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gesture-grid {
|
.gesture-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2094,14 +1983,18 @@ html[data-lang="zh"] .download-panel h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.platform-symbol {
|
.platform-symbol {
|
||||||
width: 92px;
|
width: 88px;
|
||||||
height: 122px;
|
height: 88px;
|
||||||
border-radius: 23px;
|
border-radius: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mac-symbol {
|
.mac-symbol {
|
||||||
height: 76px;
|
border-radius: 21px;
|
||||||
border-radius: 16px;
|
}
|
||||||
|
|
||||||
|
.platform-symbol svg {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-steps {
|
.setup-steps {
|
||||||
|
|||||||
+13
-3
@@ -58,15 +58,20 @@
|
|||||||
element.setAttribute("content", element.dataset[`${lang}Content`] || "");
|
element.setAttribute("content", element.dataset[`${lang}Content`] || "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-shot]").forEach((image) => {
|
||||||
|
const theme = root.dataset.theme || "light";
|
||||||
|
image.src = `assets/screenshots/${lang}/${theme}/${image.dataset.shot}`;
|
||||||
|
});
|
||||||
|
|
||||||
document.title = lang === "zh"
|
document.title = lang === "zh"
|
||||||
? "OSGKeyboard — 语音、打字与 AI 助手键盘"
|
? "OSGKeyboard — 语音输入、中英打字与 AI 助手"
|
||||||
: "OSGKeyboard — Voice, typing, and an AI assistant keyboard";
|
: "OSGKeyboard — Voice input, bilingual typing, and an AI assistant";
|
||||||
|
|
||||||
if (languageButton) {
|
if (languageButton) {
|
||||||
languageButton.textContent = lang === "zh" ? "EN" : "中文";
|
languageButton.textContent = lang === "zh" ? "EN" : "中文";
|
||||||
languageButton.setAttribute(
|
languageButton.setAttribute(
|
||||||
"aria-label",
|
"aria-label",
|
||||||
lang === "zh" ? "Switch to English" : "切换到中文"
|
lang === "zh" ? "切换到英文" : "Switch to Chinese"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +136,11 @@
|
|||||||
|
|
||||||
themeColor?.setAttribute("content", resolved === "dark" ? "#0c0e0f" : "#eef2f6");
|
themeColor?.setAttribute("content", resolved === "dark" ? "#0c0e0f" : "#eef2f6");
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-shot]").forEach((image) => {
|
||||||
|
const lang = root.dataset.lang || "zh";
|
||||||
|
image.src = `assets/screenshots/${lang}/${resolved}/${image.dataset.shot}`;
|
||||||
|
});
|
||||||
|
|
||||||
if (persist) localStorage.setItem("osg-site-theme", resolved);
|
if (persist) localStorage.setItem("osg-site-theme", resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
+149
-141
@@ -3,12 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>OSGKeyboard — 语音、打字与 AI 助手键盘</title>
|
<title>OSGKeyboard — 语音输入、中英打字与 AI 助手</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="OSGKeyboard 是适用于 iPhone、iPad 和 Mac 的语音输入工具。iOS 键盘集成语音听写、中英打字、AI 助手、剪贴板技能与可选托管积分;默认使用端侧识别。"
|
content="OSGKeyboard 是一款适用于 iPhone、iPad 和 Mac 的语音与双语输入工具。iOS、iPadOS 版集成听写、中英输入、AI 助手、剪贴板技能和可选的托管积分服务,并默认使用端侧语音识别。"
|
||||||
data-zh-content="OSGKeyboard 是适用于 iPhone、iPad 和 Mac 的语音输入工具。iOS 键盘集成语音听写、中英打字、AI 助手、剪贴板技能与可选托管积分;默认使用端侧识别。"
|
data-zh-content="OSGKeyboard 是一款适用于 iPhone、iPad 和 Mac 的语音与双语输入工具。iOS、iPadOS 版集成听写、中英输入、AI 助手、剪贴板技能和可选的托管积分服务,并默认使用端侧语音识别。"
|
||||||
data-en-content="OSGKeyboard brings voice input to iPhone, iPad, and Mac. Its iOS keyboard combines dictation, Chinese and English typing, an AI assistant, clipboard skills, and optional managed credits—with on-device recognition by default."
|
data-en-content="OSGKeyboard brings voice and bilingual input to iPhone, iPad, and Mac. Its custom keyboard for iOS and iPadOS combines dictation, Chinese and English typing, an AI assistant, clipboard skills, and optional managed credits—with on-device recognition by default."
|
||||||
>
|
>
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
<meta name="theme-color" content="#eef2f6">
|
<meta name="theme-color" content="#eef2f6">
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
property="og:title"
|
property="og:title"
|
||||||
content="OSGKeyboard — 开口即文字"
|
content="OSGKeyboard — 开口即文字"
|
||||||
data-zh-content="OSGKeyboard — 开口即文字"
|
data-zh-content="OSGKeyboard — 开口即文字"
|
||||||
data-en-content="OSGKeyboard — Speak it. It's typed."
|
data-en-content="OSGKeyboard — Say it. It’s typed."
|
||||||
>
|
>
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="一个键盘,完成听写、中英打字、AI 问答与剪贴板技能。默认端侧识别,也支持 BYOK 和可选托管积分。"
|
content="用一把键盘完成听写、中英输入、AI 问答和剪贴板技能。默认使用端侧识别,也可配置自己的 API 密钥或使用托管积分服务。"
|
||||||
data-zh-content="一个键盘,完成听写、中英打字、AI 问答与剪贴板技能。默认端侧识别,也支持 BYOK 和可选托管积分。"
|
data-zh-content="用一把键盘完成听写、中英输入、AI 问答和剪贴板技能。默认使用端侧识别,也可配置自己的 API 密钥或使用托管积分服务。"
|
||||||
data-en-content="One keyboard for dictation, Chinese and English typing, AI answers, and clipboard skills. On-device by default, with BYOK and optional managed credits."
|
data-en-content="One keyboard for dictation, Chinese and English typing, AI answers, and clipboard skills. On-device by default, with your own API key or optional managed credits."
|
||||||
>
|
>
|
||||||
<meta property="og:image" content="https://hkgood.github.io/OSGKeyboard/assets/app-icon.png">
|
<meta property="og:image" content="https://hkgood.github.io/OSGKeyboard/assets/app-icon.png">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"operatingSystem": "iOS 26, iPadOS 26, macOS 15",
|
"operatingSystem": "iOS 26, iPadOS 26, macOS 15",
|
||||||
"applicationCategory": "UtilitiesApplication",
|
"applicationCategory": "UtilitiesApplication",
|
||||||
"softwareVersion": "2.0.0",
|
"softwareVersion": "2.0.0",
|
||||||
"description": "A source-available voice and typing keyboard with on-device speech recognition by default, Chinese and English typing, an AI assistant, clipboard skills, BYOK providers, and optional managed credits.",
|
"description": "A source-available voice and typing keyboard with on-device speech recognition by default, Chinese and English typing, an AI assistant, clipboard skills, providers configured with your own API key, and optional managed credits.",
|
||||||
"downloadUrl": "https://apps.apple.com/app/osgkeyboard/id6781553267",
|
"downloadUrl": "https://apps.apple.com/app/osgkeyboard/id6781553267",
|
||||||
"url": "https://hkgood.github.io/OSGKeyboard/",
|
"url": "https://hkgood.github.io/OSGKeyboard/",
|
||||||
"offers": {
|
"offers": {
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
"Unified dictation and AI assistant keyboard",
|
"Unified dictation and AI assistant keyboard",
|
||||||
"Clipboard history and AI skills",
|
"Clipboard history and AI skills",
|
||||||
"Voice editing of the last verified insertion",
|
"Voice editing of the last verified insertion",
|
||||||
"Optional Sign in with Apple and managed credits on iOS",
|
"Optional Sign in with Apple and managed credits on iOS and iPadOS",
|
||||||
"Bring-your-own-key cloud speech and AI",
|
"Cloud speech and AI with your own API key",
|
||||||
"Mac Option-key global dictation with local Qwen3 MLX on Apple Silicon",
|
"Mac Option-key global dictation with local Qwen3 MLX on Apple Silicon",
|
||||||
"Personal dictionary and optional iCloud sync"
|
"Personal dictionary and optional iCloud sync"
|
||||||
],
|
],
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"name": "Does OSGKeyboard require an account?",
|
"name": "Does OSGKeyboard require an account?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "No account is required for local dictation or providers you configure yourself. Sign in with Apple is optional and is used for managed credits, purchases, and referrals."
|
"text": "You do not need an OSG account for on-device dictation or when using providers you configure yourself. Sign in with Apple is optional and is used for managed credits, purchases, and referrals."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"name": "Does OSGKeyboard upload recordings?",
|
"name": "Does OSGKeyboard upload recordings?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "Local recognition keeps audio on the device. Audio leaves the device only after you explicitly choose a user-configured cloud engine or the managed-credit cloud path."
|
"text": "On-device recognition keeps audio on your device. Audio leaves it only when you explicitly choose a cloud speech-recognition provider configured with your own API key or cloud recognition paid for with managed credits."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"name": "Is OSGKeyboard open source?",
|
"name": "Is OSGKeyboard open source?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "The code is source available for audit and personal, non-commercial local use. It is not an open-source or MIT license."
|
"text": "The code is source available under a non-open-source license for audit and personal, non-commercial local use; it is not licensed under the MIT License."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
>跳到主要内容</a>
|
>跳到主要内容</a>
|
||||||
|
|
||||||
<header class="site-nav">
|
<header class="site-nav">
|
||||||
<nav class="nav-shell" aria-label="Primary">
|
<nav class="nav-shell" aria-label="主导航" data-zh-label="主导航" data-en-label="Primary navigation">
|
||||||
<a class="brand" href="#top" aria-label="OSGKeyboard">
|
<a class="brand" href="#top" aria-label="OSGKeyboard">
|
||||||
<img src="assets/app-icon.png" width="30" height="30" alt="">
|
<img src="assets/app-icon.png" width="30" height="30" alt="">
|
||||||
<span>OSGKeyboard</span>
|
<span>OSGKeyboard</span>
|
||||||
@@ -144,13 +144,13 @@
|
|||||||
>
|
>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">menu</span>
|
<span class="material-symbols-rounded" aria-hidden="true">menu</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="language-button" id="languageToggle" type="button" aria-label="Switch to English">EN</button>
|
<button class="language-button" id="languageToggle" type="button" aria-label="切换到英文">EN</button>
|
||||||
<button class="icon-button" id="themeToggle" type="button" aria-label="切换到深色模式">
|
<button class="icon-button" id="themeToggle" type="button" aria-label="切换到深色模式">
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">dark_mode</span>
|
<span class="material-symbols-rounded" aria-hidden="true">dark_mode</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<nav class="mobile-nav" id="mobileNav" aria-label="Mobile" hidden>
|
<nav class="mobile-nav" id="mobileNav" aria-label="移动端导航" data-zh-label="移动端导航" data-en-label="Mobile navigation" hidden>
|
||||||
<a href="#assistant" data-zh="体验" data-en="Experience">体验</a>
|
<a href="#assistant" data-zh="体验" data-en="Experience">体验</a>
|
||||||
<a href="#typing" data-zh="输入" data-en="Typing">输入</a>
|
<a href="#typing" data-zh="输入" data-en="Typing">输入</a>
|
||||||
<a href="#privacy" data-zh="隐私" data-en="Privacy">隐私</a>
|
<a href="#privacy" data-zh="隐私" data-en="Privacy">隐私</a>
|
||||||
@@ -165,19 +165,19 @@
|
|||||||
<div class="hero-inner">
|
<div class="hero-inner">
|
||||||
<p class="launch-pill">
|
<p class="launch-pill">
|
||||||
<span class="launch-dot" aria-hidden="true"></span>
|
<span class="launch-dot" aria-hidden="true"></span>
|
||||||
<span data-zh="OSGKeyboard 2.0 · 为 iOS 26 而生" data-en="OSGKeyboard 2.0 · Built for iOS 26">
|
<span data-zh="OSGKeyboard 2.0 · 适配 iOS / iPadOS 26" data-en="OSGKeyboard 2.0 · Built for iOS and iPadOS 26">
|
||||||
OSGKeyboard 2.0 · 为 iOS 26 而生
|
OSGKeyboard 2.0 · 适配 iOS / iPadOS 26
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<h1>
|
<h1>
|
||||||
<span data-zh="声音,直达输入框。" data-en="From voice to text.">声音,直达输入框。</span>
|
<span data-zh="开口,文字直达输入框。" data-en="From voice to text.">开口,文字直达输入框。</span>
|
||||||
<span class="hero-muted" data-zh="不用离开正在做的事。" data-en="Right where you type.">不用离开正在做的事。</span>
|
<span class="hero-muted" data-zh="无需离开当前 App。" data-en="Without leaving your current app.">无需离开当前 App。</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="hero-copy"
|
class="hero-copy"
|
||||||
data-zh="在任意 App 里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。"
|
data-zh="在支持第三方键盘的输入框里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。"
|
||||||
data-en="Dictate, type, ask, and edit in any app. OSGKeyboard brings voice recognition, bilingual typing, and an AI assistant into one keyboard."
|
data-en="Dictate, type, ask, and edit in text fields that support custom keyboards. OSGKeyboard brings voice recognition, bilingual typing, and an AI assistant into one keyboard."
|
||||||
>在任意 App 里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。</p>
|
>在支持第三方键盘的输入框里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a
|
<a
|
||||||
class="store-badge"
|
class="store-badge"
|
||||||
@@ -190,9 +190,9 @@
|
|||||||
src="assets/badges/ios-zh.svg"
|
src="assets/badges/ios-zh.svg"
|
||||||
data-zh-src="assets/badges/ios-zh.svg"
|
data-zh-src="assets/badges/ios-zh.svg"
|
||||||
data-en-src="assets/badges/ios-en.svg"
|
data-en-src="assets/badges/ios-en.svg"
|
||||||
data-zh-alt="立即下载 App Store 版"
|
data-zh-alt="在 App Store 下载 OSGKeyboard"
|
||||||
data-en-alt="Download on the App Store"
|
data-en-alt="Download on the App Store"
|
||||||
alt="立即下载 App Store 版"
|
alt="在 App Store 下载 OSGKeyboard"
|
||||||
width="171"
|
width="171"
|
||||||
height="48"
|
height="48"
|
||||||
>
|
>
|
||||||
@@ -207,10 +207,16 @@
|
|||||||
<p
|
<p
|
||||||
class="hero-note"
|
class="hero-note"
|
||||||
data-zh="iOS / iPadOS 26+ · 核心功能无需 OSG 账号 · 默认端侧识别"
|
data-zh="iOS / iPadOS 26+ · 核心功能无需 OSG 账号 · 默认端侧识别"
|
||||||
data-en="iOS / iPadOS 26+ · No OSG account for core features · On-device by default"
|
data-en="iOS and iPadOS 26+ · No OSG account required for core features · On-device recognition by default"
|
||||||
>iOS / iPadOS 26+ · 核心功能无需 OSG 账号 · 默认端侧识别</p>
|
>iOS / iPadOS 26+ · 核心功能无需 OSG 账号 · 默认端侧识别</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="scroll-cue" href="#assistant" aria-label="Explore">
|
<a
|
||||||
|
class="scroll-cue"
|
||||||
|
href="#assistant"
|
||||||
|
aria-label="看看它如何工作"
|
||||||
|
data-zh-label="看看它如何工作"
|
||||||
|
data-en-label="See how it works"
|
||||||
|
>
|
||||||
<span data-zh="看看它如何工作" data-en="See how it works">看看它如何工作</span>
|
<span data-zh="看看它如何工作" data-en="See how it works">看看它如何工作</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">south</span>
|
<span class="material-symbols-rounded" aria-hidden="true">south</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -220,18 +226,18 @@
|
|||||||
<section class="section product-section" id="assistant">
|
<section class="section product-section" id="assistant">
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="center-heading">
|
<div class="center-heading">
|
||||||
<p class="eyebrow" data-zh="统一助手键盘" data-en="Unified assistant keyboard">统一助手键盘</p>
|
<p class="eyebrow" data-zh="语音与 AI 助手" data-en="Voice and AI assistant">语音与 AI 助手</p>
|
||||||
<h2 data-zh="一句话,从想法直接到输入框。" data-en="One thought. Ready to use where you are.">一句话,从想法直接到输入框。</h2>
|
<h2 data-zh="说出想法,预览后再插入。" data-en="Speak your thought, preview the result, then insert it.">说出想法,预览后再插入。</h2>
|
||||||
<p
|
<p
|
||||||
data-zh="轻点麦克风开始听写,长按直接向 AI 提问。结果先显示、后插入,让每一步都可见、可控。"
|
data-zh="轻点麦克风开始听写,长按直接向 AI 提问。结果会先供你预览,确认后再插入,让每一步都清晰可控。"
|
||||||
data-en="Tap the microphone to dictate, or hold it to ask AI. Results appear before insertion, so every step stays visible and under your control."
|
data-en="Tap the microphone to dictate, or hold it to ask AI. Preview the result before inserting it, so every step stays clear and under your control."
|
||||||
>轻点麦克风开始听写,长按直接向 AI 提问。结果先显示、后插入,让每一步都可见、可控。</p>
|
>轻点麦克风开始听写,长按直接向 AI 提问。结果会先供你预览,确认后再插入,让每一步都清晰可控。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-stage">
|
<div class="product-stage">
|
||||||
<div class="stage-bar">
|
<div class="stage-bar">
|
||||||
<span class="stage-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
<span class="stage-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
<span data-zh="任意 App · 功能示意" data-en="Any app · Product illustration">任意 App · 功能示意</span>
|
<span data-zh="支持第三方键盘的 App · 交互示意与实机画面" data-en="App with custom-keyboard support · Interaction illustration and real keyboard screenshot">支持第三方键盘的 App · 交互示意与实机画面</span>
|
||||||
<span class="stage-status" data-zh="本地优先" data-en="Local first">本地优先</span>
|
<span class="stage-status" data-zh="本地优先" data-en="Local first">本地优先</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stage-content">
|
<div class="stage-content">
|
||||||
@@ -245,30 +251,27 @@
|
|||||||
<div class="insert-preview">
|
<div class="insert-preview">
|
||||||
<span class="preview-kicker" data-zh="准备插入" data-en="Ready to insert">准备插入</span>
|
<span class="preview-kicker" data-zh="准备插入" data-en="Ready to insert">准备插入</span>
|
||||||
<p
|
<p
|
||||||
data-zh="内容已经整理得更清楚,同时保留了原本的表达方式。"
|
data-zh="已理清内容,同时保留原有语气。"
|
||||||
data-en="The message is clearer while preserving your original tone."
|
data-en="The message is clearer while preserving your original tone."
|
||||||
>内容已经整理得更清楚,同时保留了原本的表达方式。</p>
|
>已理清内容,同时保留原有语气。</p>
|
||||||
<span class="text-cursor" aria-hidden="true"></span>
|
<span class="text-cursor" aria-hidden="true"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="keyboard-demo" aria-hidden="true">
|
<figure
|
||||||
<div class="assistant-tabs">
|
class="keyboard-capture"
|
||||||
<span class="assistant-logo">OSG</span>
|
aria-label="OSGKeyboard 自定义键盘实机画面"
|
||||||
<span class="assistant-tab active" data-zh="助手" data-en="Assistant">助手</span>
|
data-zh-label="OSGKeyboard 自定义键盘实机画面"
|
||||||
<span class="assistant-tab">中文</span>
|
data-en-label="Actual OSGKeyboard custom keyboard"
|
||||||
<span class="assistant-tab">EN</span>
|
>
|
||||||
<span class="material-symbols-rounded">more_horiz</span>
|
<img
|
||||||
</div>
|
src="assets/screenshots/zh/light/iphone-keyboard_recording.png"
|
||||||
<div class="keyboard-keys">
|
data-shot="iphone-keyboard_recording.png"
|
||||||
<span>Q</span><span>W</span><span>E</span><span>R</span><span>T</span><span>Y</span><span>U</span><span>I</span><span>O</span><span>P</span>
|
data-zh-alt="OSGKeyboard 在 iPhone 输入框中进行语音输入"
|
||||||
<span>A</span><span>S</span><span>D</span><span>F</span><span>G</span><span>H</span><span>J</span><span>K</span><span>L</span>
|
data-en-alt="OSGKeyboard dictating into an iPhone text field"
|
||||||
</div>
|
alt="OSGKeyboard 在 iPhone 输入框中进行语音输入"
|
||||||
<div class="assistant-controls">
|
>
|
||||||
<span class="round-control"><span class="material-symbols-rounded">undo</span></span>
|
<figcaption data-zh="iPhone 实机键盘画面" data-en="Actual OSGKeyboard on iPhone">iPhone 实机键盘画面</figcaption>
|
||||||
<span class="mic-control"><span class="material-symbols-rounded">mic</span></span>
|
</figure>
|
||||||
<span class="round-control"><span class="material-symbols-rounded">arrow_upward</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,20 +279,20 @@
|
|||||||
<article>
|
<article>
|
||||||
<span class="gesture-number">01</span>
|
<span class="gesture-number">01</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">mic</span>
|
<span class="material-symbols-rounded" aria-hidden="true">mic</span>
|
||||||
<h3 data-zh="说出意图" data-en="Express intent">说出意图</h3>
|
<h3 data-zh="说出需求" data-en="Say what you need">说出需求</h3>
|
||||||
<p data-zh="轻点听写,长按提问。" data-en="Tap to dictate, hold to ask.">轻点听写,长按提问。</p>
|
<p data-zh="轻点听写,长按提问。" data-en="Tap to dictate, hold to ask.">轻点听写,长按提问。</p>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<span class="gesture-number">02</span>
|
<span class="gesture-number">02</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">auto_fix_high</span>
|
<span class="material-symbols-rounded" aria-hidden="true">auto_fix_high</span>
|
||||||
<h3 data-zh="理解并处理" data-en="Understand and process">理解并处理</h3>
|
<h3 data-zh="理解并处理" data-en="Understand and process">理解并处理</h3>
|
||||||
<p data-zh="润色、问答或编辑上次输入。" data-en="Polish, answer, or edit the last input.">润色、问答或编辑上次输入。</p>
|
<p data-zh="润色文字、回答问题,或修改上次输入。" data-en="Polish text, answer a question, or revise the last input.">润色文字、回答问题,或修改上次输入。</p>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<span class="gesture-number">03</span>
|
<span class="gesture-number">03</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">text_fields</span>
|
<span class="material-symbols-rounded" aria-hidden="true">text_fields</span>
|
||||||
<h3 data-zh="确认后插入" data-en="Preview, then insert">确认后插入</h3>
|
<h3 data-zh="确认后插入" data-en="Preview, then insert">确认后插入</h3>
|
||||||
<p data-zh="只有光标和上下文匹配时才自动上屏。" data-en="Auto-insert only when cursor context still matches.">只有光标和上下文匹配时才自动上屏。</p>
|
<p data-zh="仅当光标位置和输入上下文未变化时,才会自动插入。" data-en="Auto-insert only if the cursor position and surrounding context have not changed.">仅当光标位置和输入上下文未变化时,才会自动插入。</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,29 +305,29 @@
|
|||||||
<p class="eyebrow" data-zh="语音输入" data-en="Voice input">语音输入</p>
|
<p class="eyebrow" data-zh="语音输入" data-en="Voice input">语音输入</p>
|
||||||
<h2 data-zh="开口即文字,不打断思路。" data-en="Speak naturally. Keep your train of thought.">开口即文字,不打断思路。</h2>
|
<h2 data-zh="开口即文字,不打断思路。" data-en="Speak naturally. Keep your train of thought.">开口即文字,不打断思路。</h2>
|
||||||
<p
|
<p
|
||||||
data-zh="本地识别可以直接开始。热词、个人词典与上下文补偿,让名字、术语和常用表达更准确。"
|
data-zh="完成系统授权后即可使用端侧识别。个人词典与上下文词语提示有助于提高名字、术语和常用表达的识别准确率。"
|
||||||
data-en="Start immediately with local recognition. Hotwords, a personal dictionary, and context compensation improve names, terminology, and phrases you use often."
|
data-en="Start with on-device recognition—no account or cloud setup required. Hotwords, a personal dictionary, and contextual corrections improve recognition of names, terminology, and phrases you use often."
|
||||||
>本地识别可以直接开始。热词、个人词典与上下文补偿,让名字、术语和常用表达更准确。</p>
|
>完成系统授权后即可使用端侧识别。个人词典与上下文词语提示有助于提高名字、术语和常用表达的识别准确率。</p>
|
||||||
<ul class="quiet-list">
|
<ul class="quiet-list">
|
||||||
<li data-zh="默认使用 Apple 端侧语音识别" data-en="Apple on-device recognition by default">默认使用 Apple 端侧语音识别</li>
|
<li data-zh="默认使用 Apple 端侧语音识别" data-en="Apple on-device recognition by default">默认使用 Apple 端侧语音识别</li>
|
||||||
<li data-zh="云端识别和 AI 润色始终由你选择" data-en="Cloud recognition and AI polish are always your choice">云端识别和 AI 润色始终由你选择</li>
|
<li data-zh="云端识别与 AI 润色均为可选" data-en="Cloud recognition and AI polishing are always optional">云端识别与 AI 润色均为可选</li>
|
||||||
<li data-zh="可用语音重写上一次已验证输入" data-en="Rewrite the last verified insertion by voice">可用语音重写上一次已验证输入</li>
|
<li data-zh="可用语音修改最近一次由 OSGKeyboard 插入的文字" data-en="Revise the most recent text inserted by OSGKeyboard using your voice">可用语音修改最近一次由 OSGKeyboard 插入的文字</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-stage">
|
<div class="voice-stage">
|
||||||
<div class="voice-meta">
|
<div class="voice-meta">
|
||||||
<span data-zh="本地 · Apple 语音识别" data-en="Local · Apple Speech">本地 · Apple 语音识别</span>
|
<span data-zh="端侧 · Apple 语音识别" data-en="On-device · Apple Speech Recognition">端侧 · Apple 语音识别</span>
|
||||||
<span class="live-indicator"><i></i><span data-zh="听写中" data-en="Listening">听写中</span></span>
|
<span class="live-indicator"><i></i><span data-zh="听写中" data-en="Listening">听写中</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="transcript-card">
|
<div class="transcript-card">
|
||||||
<span class="transcript-before" data-zh="今天下午把新版本上线计划" data-en="This afternoon, review the launch plan">今天下午把新版本上线计划</span>
|
<span class="transcript-before" data-zh="今天下午和产品团队确认一下" data-en="This afternoon, review the launch plan">今天下午和产品团队确认一下</span>
|
||||||
<strong data-zh="和产品团队确认一下。" data-en="with the product team.">和产品团队确认一下。</strong>
|
<strong data-zh="新版本上线计划。" data-en="with the product team.">新版本上线计划。</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="waveform" aria-hidden="true">
|
<div class="waveform" aria-hidden="true">
|
||||||
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
|
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-footer">
|
<div class="voice-footer">
|
||||||
<span data-zh="已识别 18 个字" data-en="18 words recognized">已识别 18 个字</span>
|
<span data-zh="已识别 20 个字" data-en="10 words recognized">已识别 20 个字</span>
|
||||||
<span data-zh="完成" data-en="Done">完成</span>
|
<span data-zh="完成" data-en="Done">完成</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,12 +339,12 @@
|
|||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="center-heading compact">
|
<div class="center-heading compact">
|
||||||
<p class="eyebrow" data-zh="中文与英文输入" data-en="Chinese and English typing">中文与英文输入</p>
|
<p class="eyebrow" data-zh="中文与英文输入" data-en="Chinese and English typing">中文与英文输入</p>
|
||||||
<h2 data-zh="不想说时,也是一把完整键盘。" data-en="A complete keyboard when you would rather type.">不想说时,也是一把完整键盘。</h2>
|
<h2 data-zh="不方便说话时,也能顺手打字。" data-en="When speaking is not convenient, typing stays effortless.">不方便说话时,也能顺手打字。</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="typing-stories">
|
<div class="typing-stories">
|
||||||
<article class="typing-story">
|
<article class="typing-story">
|
||||||
<div class="typing-visual chinese-visual">
|
<div class="typing-visual chinese-visual">
|
||||||
<span class="typing-label" data-zh="全拼 / 双拼" data-en="Full / double pinyin">全拼 / 双拼</span>
|
<span class="typing-label" data-zh="全拼 / 双拼" data-en="Full pinyin / double pinyin">全拼 / 双拼</span>
|
||||||
<div class="composition-row"><strong>jintian</strong><span class="text-cursor" aria-hidden="true"></span></div>
|
<div class="composition-row"><strong>jintian</strong><span class="text-cursor" aria-hidden="true"></span></div>
|
||||||
<div class="candidate-row">
|
<div class="candidate-row">
|
||||||
<span class="selected">今天</span><span>今天的</span><span>近天</span><span>金田</span>
|
<span class="selected">今天</span><span>今天的</span><span>近天</span><span>金田</span>
|
||||||
@@ -352,11 +355,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<span class="story-index">01</span>
|
<span class="story-index">01</span>
|
||||||
<h3 data-zh="中文输入,熟悉而完整。" data-en="Chinese typing that feels complete.">中文输入,熟悉而完整。</h3>
|
<h3 data-zh="全拼、双拼,输入方式由你选。" data-en="Choose full pinyin or double pinyin.">全拼、双拼,输入方式由你选。</h3>
|
||||||
<p
|
<p
|
||||||
data-zh="全拼、自然码、小鹤、微软与搜狗双拼;候选分页、数字选词、滑动选词与中英混输一并具备。"
|
data-zh="支持全拼、自然码、小鹤、微软与搜狗双拼,同时提供候选分页、数字选词、滑动选词与中英混输。"
|
||||||
data-en="Full pinyin plus Natural Code, Xiaohe, Microsoft, and Sogou double pinyin—with candidate paging, number selection, swiping, and mixed Chinese-English input."
|
data-en="Full pinyin plus Ziranma, Xiaohe, Microsoft, and Sogou double pinyin—with candidate paging, number selection, swiping, and mixed Chinese-English input."
|
||||||
>全拼、自然码、小鹤、微软与搜狗双拼;候选分页、数字选词、滑动选词与中英混输一并具备。</p>
|
>支持全拼、自然码、小鹤、微软与搜狗双拼,同时提供候选分页、数字选词、滑动选词与中英混输。</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="typing-story reverse">
|
<article class="typing-story reverse">
|
||||||
@@ -368,11 +371,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<span class="story-index">02</span>
|
<span class="story-index">02</span>
|
||||||
<h3 data-zh="英文输入,越用越顺手。" data-en="English typing that gets out of your way.">英文输入,越用越顺手。</h3>
|
<h3 data-zh="英文输入,同样顺手。" data-en="English typing that gets out of your way.">英文输入,同样顺手。</h3>
|
||||||
<p
|
<p
|
||||||
data-zh="前缀补全、自动纠错、下一词预测、候选撤销与句首大小写,让双语切换保持同样流畅。"
|
data-zh="支持前缀补全、自动纠错、下一词预测、撤销自动纠错和句首自动大写,中英文切换也能保持流畅。"
|
||||||
data-en="Prefix completion, autocorrect, next-word prediction, candidate undo, and sentence capitalization keep bilingual typing fluid."
|
data-en="Prefix completion, autocorrect, next-word prediction, candidate undo, and sentence capitalization keep bilingual typing fluid."
|
||||||
>前缀补全、自动纠错、下一词预测、候选撤销与句首大小写,让双语切换保持同样流畅。</p>
|
>支持前缀补全、自动纠错、下一词预测、撤销自动纠错和句首自动大写,中英文切换也能保持流畅。</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,21 +408,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scene-copy">
|
<div class="scene-copy">
|
||||||
<p class="eyebrow" data-zh="剪贴板与快捷技能" data-en="Clipboard and quick skills">剪贴板与快捷技能</p>
|
<p class="eyebrow" data-zh="剪贴板与快捷技能" data-en="Clipboard and quick skills">剪贴板与快捷技能</p>
|
||||||
<h2 data-zh="复制之后,直接行动。" data-en="After copy comes action.">复制之后,直接行动。</h2>
|
<h2 data-zh="复制之后,直接行动。" data-en="Copy, then take action.">复制之后,直接行动。</h2>
|
||||||
<p
|
<p
|
||||||
data-zh="从剪贴板历史继续工作,把已有文字变成回复、总结、翻译、待办、日程或导航。所有读取都由你主动开启。"
|
data-zh="从剪贴板历史中选择内容,生成回复、摘要或译文,也可提取待办、日程和地址并发起导航。剪贴板访问默认关闭,仅在你主动开启后读取。"
|
||||||
data-en="Continue from clipboard history and turn existing text into a reply, summary, translation, task, event, or navigation. Clipboard access remains opt-in."
|
data-en="Use your clipboard history to draft a reply, summarize or translate text, create a task or event, or get directions. Clipboard access remains opt-in."
|
||||||
>从剪贴板历史继续工作,把已有文字变成回复、总结、翻译、待办、日程或导航。所有读取都由你主动开启。</p>
|
>从剪贴板历史中选择内容,生成回复、摘要或译文,也可提取待办、日程和地址并发起导航。剪贴板访问默认关闭,仅在你主动开启后读取。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="styles-strip">
|
<div class="styles-strip">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow" data-zh="个性化润色" data-en="Personalized polish">个性化润色</p>
|
<p class="eyebrow" data-zh="个性化润色" data-en="Personalized polish">个性化润色</p>
|
||||||
<h3 data-zh="整理表达,但不要改掉你的语气。" data-en="Improve the writing without erasing your voice.">整理表达,但不要改掉你的语气。</h3>
|
<h3 data-zh="表达更清楚,语气还是你的。" data-en="Improve the writing without erasing your voice.">表达更清楚,语气还是你的。</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="style-options">
|
<div class="style-options">
|
||||||
<span data-zh="轻度清理" data-en="Light cleanup">轻度清理</span>
|
<span data-zh="轻度润色" data-en="Light polish">轻度润色</span>
|
||||||
<span class="selected" data-zh="清晰结构" data-en="Clear structure">清晰结构</span>
|
<span class="selected" data-zh="理清结构" data-en="Clarify structure">理清结构</span>
|
||||||
<span data-zh="正式表达" data-en="Formal">正式表达</span>
|
<span data-zh="正式表达" data-en="Formal">正式表达</span>
|
||||||
<span data-zh="日常聊天" data-en="Casual">日常聊天</span>
|
<span data-zh="日常聊天" data-en="Casual">日常聊天</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,46 +435,46 @@
|
|||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="center-heading inverse">
|
<div class="center-heading inverse">
|
||||||
<p class="eyebrow" data-zh="隐私与选择" data-en="Privacy and choice">隐私与选择</p>
|
<p class="eyebrow" data-zh="隐私与选择" data-en="Privacy and choice">隐私与选择</p>
|
||||||
<h2 data-zh="输入路径,由你决定。" data-en="You choose how your input is processed.">输入路径,由你决定。</h2>
|
<h2 data-zh="数据如何处理,由你决定。" data-en="You choose how your data is processed.">数据如何处理,由你决定。</h2>
|
||||||
<p
|
<p
|
||||||
data-zh="先使用本地能力。需要更强模型时,再选择自己的 API Key 或可选托管积分。路径透明,决定权始终在你。"
|
data-zh="默认优先使用端侧能力。需要云端识别或 AI 功能时,可配置自己的 API 密钥,或选择托管积分服务。每种处理方式都会清晰标示,决定权始终在你。"
|
||||||
data-en="Start locally. When you need a stronger model, choose your own API key or optional managed credits. The path stays explicit, and the decision stays yours."
|
data-en="On-device processing is the default. For cloud recognition or AI features, provide your own API key or use optional managed credits. The active option is always clearly shown."
|
||||||
>先使用本地能力。需要更强模型时,再选择自己的 API Key 或可选托管积分。路径透明,决定权始终在你。</p>
|
>默认优先使用端侧能力。需要云端识别或 AI 功能时,可配置自己的 API 密钥,或选择托管积分服务。每种处理方式都会清晰标示,决定权始终在你。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="service-paths">
|
<div class="service-paths">
|
||||||
<article>
|
<article>
|
||||||
<span class="path-number">01</span>
|
<span class="path-number">01</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">memory</span>
|
<span class="material-symbols-rounded" aria-hidden="true">memory</span>
|
||||||
<h3 data-zh="端侧优先" data-en="On-device first">端侧优先</h3>
|
<h3 data-zh="端侧优先" data-en="On-device first">端侧优先</h3>
|
||||||
<p data-zh="默认 Apple 本地识别;Mac Apple Silicon 还可使用 Qwen3 MLX。" data-en="Apple local recognition by default; Qwen3 MLX is also available on Apple Silicon Macs.">
|
<p data-zh="iOS、iPadOS 默认使用 Apple 设备端语音识别;搭载 Apple 芯片的 Mac 默认使用 Qwen3 MLX,Apple 语音识别作为备用方案。" data-en="iOS and iPadOS use Apple on-device speech recognition by default. Apple Silicon Macs use Qwen3 MLX by default, with Apple Speech Recognition as a fallback.">
|
||||||
默认 Apple 本地识别;Mac Apple Silicon 还可使用 Qwen3 MLX。
|
iOS、iPadOS 默认使用 Apple 设备端语音识别;搭载 Apple 芯片的 Mac 默认使用 Qwen3 MLX,Apple 语音识别作为备用方案。
|
||||||
</p>
|
</p>
|
||||||
<span class="path-note" data-zh="无需账号 · 音频留在设备" data-en="No account · Audio stays on device">无需账号 · 音频留在设备</span>
|
<span class="path-note" data-zh="无需 OSG 账号 · 音频留在设备" data-en="No OSG account required · Audio stays on device">无需 OSG 账号 · 音频留在设备</span>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<span class="path-number">02</span>
|
<span class="path-number">02</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">key</span>
|
<span class="material-symbols-rounded" aria-hidden="true">key</span>
|
||||||
<h3 data-zh="自备 API Key" data-en="Bring your own key">自备 API Key</h3>
|
<h3 data-zh="使用自己的 API 密钥" data-en="Use your own API key">使用自己的 API 密钥</h3>
|
||||||
<p data-zh="连接你选择的语音或 AI 服务商。凭证保存在 Keychain。" data-en="Connect the speech or AI provider you choose. Credentials stay in Keychain.">
|
<p data-zh="配置你选择的语音识别或 AI 服务商;凭证存储在系统钥匙串中。" data-en="Configure the speech-recognition or AI provider you choose. Credentials are securely stored in the system Keychain.">
|
||||||
连接你选择的语音或 AI 服务商。凭证保存在 Keychain。
|
配置你选择的语音识别或 AI 服务商;凭证存储在系统钥匙串中。
|
||||||
</p>
|
</p>
|
||||||
<span class="path-note" data-zh="无需 OSG 账号 · 服务商直接处理" data-en="No OSG account · Provider processes directly">无需 OSG 账号 · 服务商直接处理</span>
|
<span class="path-note" data-zh="无需 OSG 账号 · 请求直达所选服务商" data-en="No OSG account required · Requests go directly to your provider">无需 OSG 账号 · 请求直达所选服务商</span>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<span class="path-number">03</span>
|
<span class="path-number">03</span>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">toll</span>
|
<span class="material-symbols-rounded" aria-hidden="true">toll</span>
|
||||||
<h3 data-zh="托管积分" data-en="Managed credits">托管积分</h3>
|
<h3 data-zh="托管积分" data-en="Managed credits">托管积分</h3>
|
||||||
<p data-zh="iOS / iPadOS 可选 Apple 登录与积分服务,无需自行管理 Key。" data-en="Optional Sign in with Apple and managed credits on iOS / iPadOS, with no key to manage.">
|
<p data-zh="在 iOS、iPadOS 上,可通过 Apple 登录使用托管积分服务,无需自行管理 API 密钥。" data-en="On iOS and iPadOS, you can optionally use Sign in with Apple and managed credits without managing an API key.">
|
||||||
iOS / iPadOS 可选 Apple 登录与积分服务,无需自行管理 Key。
|
在 iOS、iPadOS 上,可通过 Apple 登录使用托管积分服务,无需自行管理 API 密钥。
|
||||||
</p>
|
</p>
|
||||||
<span class="path-note" data-zh="明确同意后使用 · 可注销账号" data-en="Explicit opt-in · Account deletion available">明确同意后使用 · 可注销账号</span>
|
<span class="path-note" data-zh="主动选择后启用 · 可注销账号" data-en="Explicit opt-in · Account deletion available">主动选择后启用 · 可注销账号</span>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="privacy-proof">
|
<div class="privacy-proof">
|
||||||
<div>
|
<div>
|
||||||
<span class="material-symbols-rounded" aria-hidden="true">shield_lock</span>
|
<span class="material-symbols-rounded" aria-hidden="true">shield_lock</span>
|
||||||
<h3 data-zh="普通击键不采集,剪贴板需主动开启。" data-en="No ordinary keystroke collection. Clipboard access is opt-in.">
|
<h3 data-zh="不会采集日常键入内容;剪贴板访问需由你主动开启。" data-en="We do not collect what you type during normal keyboard use. Clipboard access is opt-in.">
|
||||||
普通击键不采集,剪贴板需主动开启。
|
不会采集日常键入内容;剪贴板访问需由你主动开启。
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<a class="inverse-link" href="privacy/">
|
<a class="inverse-link" href="privacy/">
|
||||||
@@ -487,16 +490,19 @@
|
|||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="center-heading compact">
|
<div class="center-heading compact">
|
||||||
<p class="eyebrow" data-zh="跨平台" data-en="Across platforms">跨平台</p>
|
<p class="eyebrow" data-zh="跨平台" data-en="Across platforms">跨平台</p>
|
||||||
<h2 data-zh="在手机上输入,在 Mac 上继续。" data-en="Type on mobile. Keep going on Mac.">在手机上输入,在 Mac 上继续。</h2>
|
<h2 data-zh="在 iPhone、iPad 上输入,在 Mac 上听写。" data-en="Type on iPhone and iPad. Dictate on Mac.">在 iPhone、iPad 上输入,在 Mac 上听写。</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="platform-stories">
|
<div class="platform-stories">
|
||||||
<article>
|
<article>
|
||||||
<div class="platform-symbol ios-symbol" aria-hidden="true">
|
<div class="platform-symbol ios-symbol" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">keyboard</span>
|
<svg viewBox="0 0 48 48" focusable="false">
|
||||||
|
<rect x="8" y="5" width="32" height="38" rx="7"></rect>
|
||||||
|
<path d="M15 17h3m5 0h3m5 0h3M15 23h3m5 0h3m5 0h3M15 29h18M20 36h8"></path>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="platform-label">iOS / iPadOS 26+</span>
|
<span class="platform-label">iOS / iPadOS 26+</span>
|
||||||
<h3 data-zh="系统键盘里的完整输入工作区" data-en="A complete input workspace in the system keyboard">系统键盘里的完整输入工作区</h3>
|
<h3 data-zh="一把键盘,覆盖完整输入流程" data-en="A complete input workspace in a custom keyboard">一把键盘,覆盖完整输入流程</h3>
|
||||||
<p data-zh="听写、AI 助手、中文与英文输入、剪贴板技能、个人词典和可选 iCloud 同步。" data-en="Dictation, AI assistant, Chinese and English typing, clipboard skills, personal dictionary, and optional iCloud sync.">
|
<p data-zh="听写、AI 助手、中文与英文输入、剪贴板技能、个人词典和可选 iCloud 同步。" data-en="Dictation, AI assistant, Chinese and English typing, clipboard skills, personal dictionary, and optional iCloud sync.">
|
||||||
听写、AI 助手、中文与英文输入、剪贴板技能、个人词典和可选 iCloud 同步。
|
听写、AI 助手、中文与英文输入、剪贴板技能、个人词典和可选 iCloud 同步。
|
||||||
</p>
|
</p>
|
||||||
@@ -504,20 +510,22 @@
|
|||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<div class="platform-symbol mac-symbol" aria-hidden="true">
|
<div class="platform-symbol mac-symbol" aria-hidden="true">
|
||||||
<span class="option-key">⌥</span>
|
<svg viewBox="0 0 48 48" focusable="false">
|
||||||
|
<path d="M7 10h11l17 28h6M26 10h15"></path>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="platform-label">macOS 15+</span>
|
<span class="platform-label">macOS 15+</span>
|
||||||
<h3 data-zh="按住 Option,在任何位置开始听写" data-en="Hold Option to dictate anywhere">按住 Option,在任何位置开始听写</h3>
|
<h3 data-zh="按住 Option 键,在支持文本输入的位置,开始听写" data-en="Hold Option to start dictation where text input is supported">按住 Option 键,在支持文本输入的位置,开始听写</h3>
|
||||||
<p data-zh="全局热键、底部实时浮层、Apple Speech 回退,以及 Apple Silicon 上的 Qwen3 MLX 本地模型。" data-en="Global hotkey, live bottom overlay, Apple Speech fallback, and a local Qwen3 MLX model on Apple Silicon.">
|
<p data-zh="支持 Option 全局热键和屏幕底部的实时听写浮窗;搭载 Apple 芯片的 Mac 使用 Qwen3 MLX 本地模型,并以 Apple 语音识别作为备用方案。" data-en="Use the Option hotkey and a live dictation overlay at the bottom of the screen. Apple Silicon Macs use a local Qwen3 MLX model, with Apple Speech Recognition as a fallback.">
|
||||||
全局热键、底部实时浮层、Apple Speech 回退,以及 Apple Silicon 上的 Qwen3 MLX 本地模型。
|
支持 Option 全局热键和屏幕底部的实时听写浮窗;搭载 Apple 芯片的 Mac 使用 Qwen3 MLX 本地模型,并以 Apple 语音识别作为备用方案。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p class="platform-footnote">
|
<p class="platform-footnote">
|
||||||
<span data-zh="公开 Mac DMG 仍为历史版 1.1;最新 Mac 2.0 能力可从源码构建。" data-en="The public Mac DMG remains historical version 1.1; build from source for the latest Mac 2.0 capabilities.">
|
<span data-zh="目前公开下载的 Mac 安装包(DMG)仍为 1.1 历史版本;如需 Mac 2.0.0 的最新功能,请从源码构建。" data-en="The publicly available Mac DMG is still version 1.1; build from source to use the latest Mac 2.0.0 capabilities.">
|
||||||
公开 Mac DMG 仍为历史版 1.1;最新 Mac 2.0 能力可从源码构建。
|
目前公开下载的 Mac 安装包(DMG)仍为 1.1 历史版本;如需 Mac 2.0.0 的最新功能,请从源码构建。
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,29 +537,29 @@
|
|||||||
<div class="split-heading">
|
<div class="split-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow" data-zh="开始使用" data-en="Get started">开始使用</p>
|
<p class="eyebrow" data-zh="开始使用" data-en="Get started">开始使用</p>
|
||||||
<h2 data-zh="两分钟,让声音进入键盘。" data-en="Put your voice in the keyboard in two minutes.">两分钟,让声音进入键盘。</h2>
|
<h2 data-zh="完成设置,开始语音输入。" data-en="Complete setup and start dictating.">完成设置,开始语音输入。</h2>
|
||||||
</div>
|
</div>
|
||||||
<p data-zh="本地路径可以直接开始。只有在选择云端能力时,才需要进一步配置。" data-en="The local path works immediately. Additional setup is needed only when you choose cloud capabilities.">
|
<p data-zh="完成系统授权后即可使用端侧识别;只有选择云端识别或 AI 功能时,才需额外配置。" data-en="After granting the required system permissions, you can use on-device recognition. Additional setup is needed only for cloud recognition or AI features.">
|
||||||
本地路径可以直接开始。只有在选择云端能力时,才需要进一步配置。
|
完成系统授权后即可使用端侧识别;只有选择云端识别或 AI 功能时,才需额外配置。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ol class="setup-steps">
|
<ol class="setup-steps">
|
||||||
<li>
|
<li>
|
||||||
<h3 data-zh="安装并授权" data-en="Install and authorize">安装并授权</h3>
|
<h3 data-zh="安装并授权" data-en="Install and authorize">安装并授权</h3>
|
||||||
<p data-zh="iOS 添加键盘并开启完全访问;Mac 授予麦克风与辅助功能权限。" data-en="Add the keyboard and enable Full Access on iOS; grant microphone and Accessibility permissions on Mac.">
|
<p data-zh="在 iOS、iPadOS 上添加键盘并开启“完全访问”;在 Mac 上授予麦克风和辅助功能权限。" data-en="Add the keyboard and enable Full Access on iOS and iPadOS; grant microphone and Accessibility permissions on Mac.">
|
||||||
iOS 添加键盘并开启完全访问;Mac 授予麦克风与辅助功能权限。
|
在 iOS、iPadOS 上添加键盘并开启“完全访问”;在 Mac 上授予麦克风和辅助功能权限。
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h3 data-zh="选择服务路径" data-en="Choose a service path">选择服务路径</h3>
|
<h3 data-zh="选择处理方式" data-en="Choose how input is processed">选择处理方式</h3>
|
||||||
<p data-zh="直接使用本地识别,或按需添加自己的 Key、登录托管积分。" data-en="Use local recognition, add your own key, or sign in for managed credits as needed.">
|
<p data-zh="使用端侧识别,或按需添加自己的 API 密钥、登录后使用托管积分。" data-en="Use on-device recognition, add your own API key, or sign in to use managed credits.">
|
||||||
直接使用本地识别,或按需添加自己的 Key、登录托管积分。
|
使用端侧识别,或按需添加自己的 API 密钥、登录后使用托管积分。
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h3 data-zh="开口或打字" data-en="Speak or type">开口或打字</h3>
|
<h3 data-zh="开口或打字" data-en="Speak or type">开口或打字</h3>
|
||||||
<p data-zh="轻点听写、长按提问,或切换到中文 / English 键盘。" data-en="Tap to dictate, hold to ask, or switch to Chinese / English typing.">
|
<p data-zh="轻点开始听写、长按进行提问,或切换到中文或英文键盘。" data-en="Tap to dictate, hold to ask, or switch between Chinese and English typing.">
|
||||||
轻点听写、长按提问,或切换到中文 / English 键盘。
|
轻点开始听写、长按进行提问,或切换到中文或英文键盘。
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -559,10 +567,10 @@
|
|||||||
<div class="stable-strip">
|
<div class="stable-strip">
|
||||||
<span data-zh="稳定性同样是功能" data-en="Reliability is a feature">稳定性同样是功能</span>
|
<span data-zh="稳定性同样是功能" data-en="Reliability is a feature">稳定性同样是功能</span>
|
||||||
<ul>
|
<ul>
|
||||||
<li data-zh="扩展冷启动保护" data-en="Extension cold-start protection">扩展冷启动保护</li>
|
<li data-zh="键盘启动保护" data-en="Reliable keyboard startup">键盘启动保护</li>
|
||||||
<li data-zh="内存压力降级" data-en="Memory-pressure fallback">内存压力降级</li>
|
<li data-zh="内存不足时自动降级" data-en="Automatic fallback when memory is low">内存不足时自动降级</li>
|
||||||
<li data-zh="上下文安全插入" data-en="Context-safe insertion">上下文安全插入</li>
|
<li data-zh="确认输入位置后再插入" data-en="Insert only after confirming the input position">确认输入位置后再插入</li>
|
||||||
<li data-zh="中断后恢复" data-en="Recovery after interruption">中断后恢复</li>
|
<li data-zh="中断后可恢复" data-en="Recover after interruptions">中断后可恢复</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -573,37 +581,37 @@
|
|||||||
<div class="section-inner faq-layout">
|
<div class="section-inner faq-layout">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow" data-zh="常见问题" data-en="FAQ">常见问题</p>
|
<p class="eyebrow" data-zh="常见问题" data-en="FAQ">常见问题</p>
|
||||||
<h2 data-zh="把重要边界说清楚。" data-en="The important boundaries, clearly stated.">把重要边界说清楚。</h2>
|
<h2 data-zh="隐私和产品限制,提前说清楚。" data-en="Clear answers about privacy and product limits.">隐私和产品限制,提前说清楚。</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="faq-list">
|
<div class="faq-list">
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary data-zh="必须注册 OSG 账号吗?" data-en="Do I need an OSG account?">必须注册 OSG 账号吗?</summary>
|
<summary data-zh="必须注册 OSG 账号吗?" data-en="Do I need an OSG account?">必须注册 OSG 账号吗?</summary>
|
||||||
<p data-zh="不需要。本地听写和自备服务商无需 OSG 账号。Apple 登录仅用于可选托管积分、购买、邀请与资料管理。" data-en="No. Local dictation and providers you configure yourself need no OSG account. Sign in with Apple is only for optional managed credits, purchases, referrals, and profile controls.">
|
<p data-zh="不需要。使用端侧听写或自行配置的服务商时,无需 OSG 账号。通过 Apple 登录仅用于托管积分、积分购买、邀请奖励和账号资料管理。" data-en="No. You do not need an OSG account for on-device dictation or when using providers you configure yourself. Sign in with Apple is used only for managed credits, credit purchases, referral rewards, and profile management.">
|
||||||
不需要。本地听写和自备服务商无需 OSG 账号。Apple 登录仅用于可选托管积分、购买、邀请与资料管理。
|
不需要。使用端侧听写或自行配置的服务商时,无需 OSG 账号。通过 Apple 登录仅用于托管积分、积分购买、邀请奖励和账号资料管理。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary data-zh="录音会上传吗?" data-en="Does my audio get uploaded?">录音会上传吗?</summary>
|
<summary data-zh="录音会上传吗?" data-en="Does my audio get uploaded?">录音会上传吗?</summary>
|
||||||
<p data-zh="默认本地识别不会上传录音。只有你明确选择自备云端识别或托管积分云端识别时,音频才会发送到对应服务路径。" data-en="Default local recognition does not upload audio. Audio leaves the device only when you explicitly select a user-configured cloud engine or managed-credit cloud recognition.">
|
<p data-zh="使用默认的端侧识别时,录音不会上传。只有你明确选择自行配置的云端识别或托管积分云端识别时,音频才会离开设备,并发送至相应云端服务进行处理。" data-en="On-device recognition does not upload your recordings. Audio leaves the device only when you explicitly choose cloud recognition configured with your own API key or cloud recognition paid for with managed credits.">
|
||||||
默认本地识别不会上传录音。只有你明确选择自备云端识别或托管积分云端识别时,音频才会发送到对应服务路径。
|
使用默认的端侧识别时,录音不会上传。只有你明确选择自行配置的云端识别或托管积分云端识别时,音频才会离开设备,并发送至相应云端服务进行处理。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary data-zh="为什么键盘需要“完全访问”?" data-en="Why does the keyboard need Full Access?">为什么键盘需要“完全访问”?</summary>
|
<summary data-zh="为什么键盘需要“完全访问”?" data-en="Why does the keyboard need Full Access?">为什么键盘需要“完全访问”?</summary>
|
||||||
<p data-zh="完全访问让键盘扩展与主 App 通过 App Group 协作、读取共享 Keychain 中的用户凭证,并在你主动开启后访问剪贴板。它不代表我们会收集普通击键。" data-en="Full Access lets the keyboard work with the host app through App Group, read user credentials from the shared Keychain, and access the clipboard only after you opt in. It does not mean ordinary keystrokes are collected.">
|
<p data-zh="开启“完全访问”后,键盘才能与主 App 共享设置、读取已保存的服务配置,并在你主动启用后访问剪贴板。此权限不代表 OSGKeyboard 会收集日常键入内容。" data-en="Full Access lets the keyboard share settings with the OSGKeyboard app, read saved provider configurations, and access the clipboard only after you opt in. We do not collect what you type during normal keyboard use.">
|
||||||
完全访问让键盘扩展与主 App 通过 App Group 协作、读取共享 Keychain 中的用户凭证,并在你主动开启后访问剪贴板。它不代表我们会收集普通击键。
|
开启“完全访问”后,键盘才能与主 App 共享设置、读取已保存的服务配置,并在你主动启用后访问剪贴板。此权限不代表 OSGKeyboard 会收集日常键入内容。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary data-zh="OSGKeyboard 是开源软件吗?" data-en="Is OSGKeyboard open source?">OSGKeyboard 是开源软件吗?</summary>
|
<summary data-zh="OSGKeyboard 是开源软件吗?" data-en="Is OSGKeyboard open source?">OSGKeyboard 是开源软件吗?</summary>
|
||||||
<p data-zh="不是。项目采用源码可见许可,允许个人学习与非商用本地使用;再分发、公开衍生版本和商业使用需要获得许可。" data-en="No. It uses a source-available license for personal learning and non-commercial local use. Redistribution, public derivatives, and commercial use require permission.">
|
<p data-zh="不是。项目采用源码可见许可,允许个人学习与非商用本地使用;再分发、公开衍生版本和商业使用需要获得许可。" data-en="No. It uses a source-available license for personal learning and non-commercial local use. Redistribution, public derivative versions, and commercial use require permission.">
|
||||||
不是。项目采用源码可见许可,允许个人学习与非商用本地使用;再分发、公开衍生版本和商业使用需要获得许可。
|
不是。项目采用源码可见许可,允许个人学习与非商用本地使用;再分发、公开衍生版本和商业使用需要获得许可。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary data-zh="Mac 下载为什么是 1.1?" data-en="Why is the Mac download version 1.1?">Mac 下载为什么是 1.1?</summary>
|
<summary data-zh="Mac 下载为什么是 1.1?" data-en="Why is the Mac download version 1.1?">Mac 下载为什么是 1.1?</summary>
|
||||||
<p data-zh="当前公开、已签名并公证的 DMG 是历史版 1.1。仓库中的 Mac target 随整体源码演进到 2.0;需要最新源码能力时可在 macOS 与 Xcode 26 中自行构建。" data-en="The currently public, signed, and notarized DMG is historical version 1.1. The Mac target in source evolves with the 2.0 codebase; build it with macOS and Xcode 26 for the latest source capabilities.">
|
<p data-zh="当前可下载、已签名并公证的 Mac 安装包为 1.1 版。仓库中的 Mac 版源码已更新至 2.0.0;如需最新功能,请在 macOS 上使用 Xcode 26 或更高版本自行构建。" data-en="The signed and notarized Mac installer currently available for download is version 1.1. The Mac source in the repository has been updated to version 2.0.0; build it on macOS with Xcode 26 or later to use the latest capabilities.">
|
||||||
当前公开、已签名并公证的 DMG 是历史版 1.1。仓库中的 Mac target 随整体源码演进到 2.0;需要最新源码能力时可在 macOS 与 Xcode 26 中自行构建。
|
当前可下载、已签名并公证的 Mac 安装包为 1.1 版。仓库中的 Mac 版源码已更新至 2.0.0;如需最新功能,请在 macOS 上使用 Xcode 26 或更高版本自行构建。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,10 +623,10 @@
|
|||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="download-panel">
|
<div class="download-panel">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow" data-zh="开口即文字" data-en="Speak it. It's typed.">开口即文字</p>
|
<p class="eyebrow" data-zh="开口即文字" data-en="Say it. It’s typed.">开口即文字</p>
|
||||||
<h2 data-zh="下一段文字,从一句话开始。" data-en="Your next sentence starts with your voice.">下一段文字,从一句话开始。</h2>
|
<h2 data-zh="下一段文字,开口就能输入。" data-en="Your next sentence starts with your voice.">下一段文字,开口就能输入。</h2>
|
||||||
<p data-zh="先从免费、本地的核心能力开始。需要时,再选择自己的服务商或托管积分。" data-en="Start with the free, local core. Add your own provider or managed credits only when you need them.">
|
<p data-zh="先使用免费的端侧核心功能;需要时,再配置自己的 API 密钥或选择托管积分服务。" data-en="Start with the free, on-device core. Add your own API key or managed credits only when needed.">
|
||||||
先从免费、本地的核心能力开始。需要时,再选择自己的服务商或托管积分。
|
先使用免费的端侧核心功能;需要时,再配置自己的 API 密钥或选择托管积分服务。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-actions">
|
<div class="download-actions">
|
||||||
@@ -633,9 +641,9 @@
|
|||||||
src="assets/badges/ios-zh.svg"
|
src="assets/badges/ios-zh.svg"
|
||||||
data-zh-src="assets/badges/ios-zh.svg"
|
data-zh-src="assets/badges/ios-zh.svg"
|
||||||
data-en-src="assets/badges/ios-en.svg"
|
data-en-src="assets/badges/ios-en.svg"
|
||||||
data-zh-alt="立即下载 App Store 版"
|
data-zh-alt="在 App Store 下载 OSGKeyboard"
|
||||||
data-en-alt="Download on the App Store"
|
data-en-alt="Download on the App Store"
|
||||||
alt="立即下载 App Store 版"
|
alt="在 App Store 下载 OSGKeyboard"
|
||||||
width="171"
|
width="171"
|
||||||
height="48"
|
height="48"
|
||||||
>
|
>
|
||||||
@@ -645,9 +653,9 @@
|
|||||||
src="assets/badges/macos-zh.svg"
|
src="assets/badges/macos-zh.svg"
|
||||||
data-zh-src="assets/badges/macos-zh.svg"
|
data-zh-src="assets/badges/macos-zh.svg"
|
||||||
data-en-src="assets/badges/macos-en.svg"
|
data-en-src="assets/badges/macos-en.svg"
|
||||||
data-zh-alt="下载 macOS 历史版本 1.1"
|
data-zh-alt="下载 macOS 1.1 旧版本"
|
||||||
data-en-alt="Download historical macOS version 1.1"
|
data-en-alt="Download macOS 1.1 (older version)"
|
||||||
alt="下载 macOS 历史版本 1.1"
|
alt="下载 macOS 1.1 旧版本"
|
||||||
width="171"
|
width="171"
|
||||||
height="48"
|
height="48"
|
||||||
>
|
>
|
||||||
@@ -669,7 +677,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<span data-zh="OSGKeyboard 2.0.0 · 源码可见,非开源软件" data-en="OSGKeyboard 2.0.0 · Source available, not open source">
|
<span data-zh="OSGKeyboard 2.0.0 · 源码可见,非开源软件" data-en="OSGKeyboard 2.0.0 · Source-available, not open source">
|
||||||
OSGKeyboard 2.0.0 · 源码可见,非开源软件
|
OSGKeyboard 2.0.0 · 源码可见,非开源软件
|
||||||
</span>
|
</span>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
|
|||||||
@@ -182,41 +182,59 @@
|
|||||||
|
|
||||||
<div class="lang-block" data-lang="zh">
|
<div class="lang-block" data-lang="zh">
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2>可选 OSG 账号</h2>
|
<h2>全新积分体验</h2>
|
||||||
<ul>
|
|
||||||
<li>新增 Apple 登录、账号资料管理和 App 内账号注销</li>
|
|
||||||
<li>新增托管积分、App Store 积分包与邀请功能</li>
|
|
||||||
<li>登录后可使用托管云端语音与 AI 服务;本地听写和用户自备 API Key 无需账号,仍可独立使用</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<div class="group">
|
||||||
<h2>助手体验升级</h2>
|
<p class="group-title">邀请好友,双方得积分</p>
|
||||||
|
<div class="wn-media wide">
|
||||||
|
<img src="assets/whats-new/referral-rewards-zh.png" alt="OSGKeyboard 设置中的邀请奖励卡片" loading="lazy" decoding="async">
|
||||||
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>语音与 AI 合并为统一的助手入口,并根据当前输入框显示对应操作</li>
|
<li>每成功邀请一位好友,你和好友各获得<strong>1000 积分</strong></li>
|
||||||
<li>AI 回答仅在输入框与光标上下文仍匹配时自动插入,避免写入错误位置</li>
|
<li>可在设置的账号区域分享邀请链接</li>
|
||||||
<li>修正退出登录与注销账号确认弹窗的锚点位置</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-title">OSG 积分模式</p>
|
||||||
|
<div class="wn-media wide">
|
||||||
|
<img src="assets/whats-new/osg-credits-zh.png" alt="OSGKeyboard AI 服务中已选择使用积分" loading="lazy" decoding="async">
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>使用 Apple 登录后,无需配置自己的 API Key,即可使用托管语音与 AI 服务</li>
|
||||||
|
<li>支持通过 App Store 购买积分包,积分余额自动同步</li>
|
||||||
|
<li>本地听写和自备 API Key 仍可独立使用,无需登录</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lang-block" data-lang="en">
|
<div class="lang-block" data-lang="en">
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2>Optional OSG Account</h2>
|
<h2>A New Credits Experience</h2>
|
||||||
<ul>
|
|
||||||
<li>Adds Sign in with Apple, profile controls, and in-app account deletion</li>
|
|
||||||
<li>Adds managed credits, App Store credit packs, and referrals</li>
|
|
||||||
<li>Signed-in users can use managed cloud speech and AI; local dictation and user-owned provider keys remain independent and require no account</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<div class="group">
|
||||||
<h2>Assistant Improvements</h2>
|
<p class="group-title">Invite Friends, Earn Credits Together</p>
|
||||||
|
<div class="wn-media wide">
|
||||||
|
<img src="assets/whats-new/referral-rewards-en.png" alt="Referral rewards card in OSGKeyboard Settings" loading="lazy" decoding="async">
|
||||||
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Combines Voice and AI into one Assistant entry with actions adapted to the focused field</li>
|
<li>For every successful referral, you and your friend each receive <strong>1,000 credits</strong></li>
|
||||||
<li>Inserts AI answers automatically only while the original field and cursor context still match</li>
|
<li>Share your invitation link from the Account section in Settings</li>
|
||||||
<li>Fixes the anchors for sign-out and account-deletion confirmation dialogs</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-title">OSG Credits Mode</p>
|
||||||
|
<div class="wn-media wide">
|
||||||
|
<img src="assets/whats-new/osg-credits-en.png" alt="Use Credits selected in OSGKeyboard AI Service" loading="lazy" decoding="async">
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>Sign in with Apple to use managed speech and AI services without configuring your own API key</li>
|
||||||
|
<li>Purchase credit packs through the App Store and keep your balance synchronized automatically</li>
|
||||||
|
<li>Local dictation and user-owned API keys remain available independently, with no sign-in required</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Privacy Policy · OSGKeyboard</title>
|
<title>Privacy Policy · OSGKeyboard</title>
|
||||||
<meta name="description" content="OSGKeyboard privacy policy: no account required for core use, on-device speech recognition by default, optional user-configured or managed cloud services, no analytics SDKs, no sale of personal data.">
|
<meta name="description" content="OSGKeyboard privacy policy: no account required for core use, on-device speech recognition by default, optional user-configured or managed cloud services, limited first-party analytics with opt-out, no sale of personal data.">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
<link rel="canonical" href="https://hkgood.github.io/OSGKeyboard/privacy/">
|
<link rel="canonical" href="https://hkgood.github.io/OSGKeyboard/privacy/">
|
||||||
<link rel="icon" href="../assets/app-icon.png" type="image/png">
|
<link rel="icon" href="../assets/app-icon.png" type="image/png">
|
||||||
@@ -52,13 +52,14 @@
|
|||||||
<li><strong>macOS local speech models</strong> — when you choose a Qwen3-ASR local model, the Mac app downloads model files from Hugging Face and stores them in its local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
<li><strong>macOS local speech models</strong> — when you choose a Qwen3-ASR local model, the Mac app downloads model files from Hugging Face and stores them in its local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
||||||
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
||||||
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, translation characters, and dictionary entry count are computed and stored locally on the home screen stats card.</li>
|
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, translation characters, and dictionary entry count are computed and stored locally on the home screen stats card.</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>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Clipboard sensitive-content filtering is applied to newly captured items. Existing history is retained until you use the confirmed clear action.</p>
|
<p>Clipboard sensitive-content filtering is applied to newly captured items. Existing history is retained until you use the confirmed clear action.</p>
|
||||||
|
|
||||||
<h2>What we do not collect</h2>
|
<h2>What we do not collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
||||||
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
<li>We do <strong>not</strong> use third-party analytics, crash-reporting, advertising SDKs, ATT, or IDFA.</li>
|
||||||
<li>We do <strong>not</strong> upload raw audio on a local engine. Audio leaves the device only after you select a user-configured cloud engine or managed credits.</li>
|
<li>We do <strong>not</strong> upload raw audio on a local engine. Audio leaves the device only after you select a user-configured cloud engine or managed credits.</li>
|
||||||
<li>We do <strong>not</strong> sell personal data.</li>
|
<li>We do <strong>not</strong> sell personal data.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
<h2>Data retention</h2>
|
<h2>Data retention</h2>
|
||||||
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account. Voice history is capped at 300 entries and can be cleared from History or by resetting settings. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and remains when capture is turned off or settings are reset; use the separate confirmed clear action to delete it. Chinese and English typing-learning data stays local and can be cleared from typing settings without deleting the personal dictionary. Downloaded Mac speech models remain until you remove the model in the app or delete its local files.</p>
|
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account. Voice history is capped at 300 entries and can be cleared from History or by resetting settings. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and remains when capture is turned off or settings are reset; use the separate confirmed clear action to delete it. Chinese and English typing-learning data stays local and can be cleared from typing settings without deleting the personal dictionary. Downloaded Mac speech models remain until you remove the model in the app or delete its local files.</p>
|
||||||
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
|
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
<h2>Children's privacy</h2>
|
<h2>Children's privacy</h2>
|
||||||
<p>OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.</p>
|
<p>OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.</p>
|
||||||
@@ -114,13 +116,14 @@
|
|||||||
<li><strong>macOS 本地语音模型</strong> — 选择 Qwen3-ASR 本地模型后,Mac App 会从 Hugging Face 下载模型文件,并保存在本机应用支持目录,直到你主动移除。本地推理不会把录音发送到 Hugging Face 或 OSGKeyboard。</li>
|
<li><strong>macOS 本地语音模型</strong> — 选择 Qwen3-ASR 本地模型后,Mac App 会从 Hugging Face 下载模型文件,并保存在本机应用支持目录,直到你主动移除。本地推理不会把录音发送到 Hugging Face 或 OSGKeyboard。</li>
|
||||||
<li><strong>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
|
<li><strong>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
|
||||||
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。</li>
|
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。</li>
|
||||||
|
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,并携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识。绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
|
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
|
||||||
|
|
||||||
<h2>我们不收集的内容</h2>
|
<h2>我们不收集的内容</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
||||||
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
<li>我们<strong>不会</strong>集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA。</li>
|
||||||
<li>使用本地引擎时,我们<strong>不会</strong>上传原始录音。只有你选择用户自备云端识别或托管积分后,录音才会离开设备。</li>
|
<li>使用本地引擎时,我们<strong>不会</strong>上传原始录音。只有你选择用户自备云端识别或托管积分后,录音才会离开设备。</li>
|
||||||
<li>我们<strong>不会</strong>出售个人数据。</li>
|
<li>我们<strong>不会</strong>出售个人数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -141,6 +144,7 @@
|
|||||||
<h2>数据保留</h2>
|
<h2>数据保留</h2>
|
||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计、词库和语音历史可能经私有 iCloud 账户同步。语音历史上限 300 条,可在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。中英文输入学习数据仅留本机,可在输入设置中单独清除且不影响个性词库。下载的 Mac 语音模型会保留到你在应用中移除模型或删除本地文件。</p>
|
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计、词库和语音历史可能经私有 iCloud 账户同步。语音历史上限 300 条,可在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。中英文输入学习数据仅留本机,可在输入设置中单独清除且不影响个性词库。下载的 Mac 语音模型会保留到你在应用中移除模型或删除本地文件。</p>
|
||||||
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
||||||
|
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期,服务端只接受 35 天以内的事件。注销账号或关闭产品分析会删除本地队列。分析标识不会用于跨 App 跟踪或广告。</p>
|
||||||
|
|
||||||
<h2>儿童隐私</h2>
|
<h2>儿童隐私</h2>
|
||||||
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
|
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
|
||||||
|
|||||||
+5
-1
@@ -49,7 +49,7 @@ settings:
|
|||||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "2.0.0"
|
MARKETING_VERSION: "2.0.0"
|
||||||
CURRENT_PROJECT_VERSION: "83"
|
CURRENT_PROJECT_VERSION: "84"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
@@ -185,6 +185,8 @@ targets:
|
|||||||
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition."
|
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition."
|
||||||
UIBackgroundModes:
|
UIBackgroundModes:
|
||||||
- audio
|
- audio
|
||||||
|
BGTaskSchedulerPermittedIdentifiers:
|
||||||
|
- com.osgkeyboard.ios.analytics-sync
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoads: false
|
NSAllowsArbitraryLoads: false
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
@@ -404,6 +406,7 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- package: Librime
|
- package: Librime
|
||||||
product: RimeStatic
|
product: RimeStatic
|
||||||
|
- sdk: libsqlite3.tbd
|
||||||
|
|
||||||
# Host-only support framework (ASR / CLM / Cloud / Charts / StoreKit).
|
# Host-only support framework (ASR / CLM / Cloud / Charts / StoreKit).
|
||||||
# Keyboard extension does NOT link this — keeps Speech/AVFoundation/Charts
|
# Keyboard extension does NOT link this — keeps Speech/AVFoundation/Charts
|
||||||
@@ -665,6 +668,7 @@ targets:
|
|||||||
- sdk: AVFoundation.framework
|
- sdk: AVFoundation.framework
|
||||||
- sdk: Charts.framework
|
- sdk: Charts.framework
|
||||||
- sdk: StoreKit.framework
|
- sdk: StoreKit.framework
|
||||||
|
- sdk: libsqlite3.tbd
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
OSGKeyboard:
|
OSGKeyboard:
|
||||||
|
|||||||
Reference in New Issue
Block a user