feat(app): add resilient Flow recovery and privacy-safe analytics
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

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:
Rocky
2026-08-20 20:22:20 +08:00
parent ee7de5c934
commit 10bc457c72
69 changed files with 7891 additions and 598 deletions
+3
View File
@@ -8,9 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
- **Free OOBE polish**: the first-run lesson defaults to on-device transcription plus OSG managed polish, sends an authenticated OOBE purpose tag, and uses a server-enforced one-time complimentary request without reserving or settling credits. / **免费 OOBE 润色**:首次教学默认使用端侧转写与 OSG 托管润色,发送经过身份验证的 OOBE 用途标签,并由服务端执行一次性免费资格,不预扣也不结算积分。
### 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. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
### Fixed
+4
View File
@@ -115,6 +115,10 @@
<array>
<string>audio</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.osgkeyboard.ios.analytics-sync</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
+39
View File
@@ -73,6 +73,45 @@
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</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>
<key>NSPrivacyAccessedAPITypes</key>
<array>
+8 -4
View File
@@ -22,7 +22,7 @@
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
<div id="top">
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> August 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>
<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>Optional OSGKeyboard account and purchases</strong> — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
<li><strong>Optional first-party product analytics</strong> — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, together with an app-scoped random installation identifier and safe app/OS version labels. It never includes keyboard input, audio, transcripts, cursor context, prompts, model output, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued events; turning it back on creates a new installation identifier.</li>
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this devices host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
<li><strong>macOS local speech models</strong> — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
@@ -44,7 +45,7 @@
<h2>What we do not collect</h2>
<ul>
<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>
</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><strong>Voice history</strong> — successful transcripts may be saved in the main apps History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
<p><strong>Clipboard history</strong> — stays in this devices App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
<p><strong>Product analytics</strong> — queued events expire locally after 34 days and the service accepts events no older than 35 days. Account deletion and analytics opt-out delete the local queue. Analytics identifiers are not used for cross-app tracking or advertising.</p>
<p><strong>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>
@@ -71,7 +73,7 @@
<hr id="zh">
<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>
<h2>我们处理的数据</h2>
@@ -84,6 +86,7 @@
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
<li><strong>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,并携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识。绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。</li>
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
@@ -93,7 +96,7 @@
<h2>我们不收集的内容</h2>
<ul>
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
<li>我们<strong>不会</strong>集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA</li>
<li>我们<strong>不会</strong>出售个人数据。</li>
</ul>
@@ -111,6 +114,7 @@
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 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>可选账号</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)"
)
}
}
+10
View File
@@ -43,6 +43,16 @@ final class AppOpenURLRouter {
}
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
/// scene-based URL delivery the only iOS 26 path to `sourceApplication`
/// reaches `AppSceneDelegate`. We deliberately do NOT create a window here;
@@ -36,14 +36,45 @@ enum FlowPiPStartOutcome: Equatable, Sendable {
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
final class FlowPictureInPictureController: NSObject {
/// User closed the PiP window host should end the Flow session.
var onUserDismissed: (() -> Void)?
protocol FlowPictureInPictureControlling: AnyObject {
var onUnexpectedStop: (() -> Void)? { get set }
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
/// True once a host UIView has been attached (may still be awaiting a window).
private(set) var hasHostView = false
/// Monotonic identity for the currently configured AVKit controller.
private(set) var generation: UInt64 = 0
let displayLayer = AVSampleBufferDisplayLayer()
@@ -115,29 +146,7 @@ final class FlowPictureInPictureController: NSObject {
@discardableResult
func start() 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 {
pipController = nil
}
configureControllerIfNeeded()
if !usesLowPowerVideoCallPiP {
warmLogoCacheIfNeeded()
animationStartedAt = CACurrentMediaTime()
startFramePump()
}
guard pipController != nil else { return false }
guard await prepareControllerForStart() else { return false }
if pipController?.isPictureInPictureActive == true {
isPictureInPictureActive = true
@@ -145,6 +154,7 @@ final class FlowPictureInPictureController: NSObject {
return true
}
guard pipController?.isPictureInPicturePossible == true else { return false }
if !usesLowPowerVideoCallPiP {
// Prime a few frames before asking the system to start PiP.
enqueueGuideFrame()
@@ -164,17 +174,23 @@ final class FlowPictureInPictureController: NSObject {
) async -> FlowPiPStartOutcome {
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)
}
let hostReady = await waitForHostInWindow(timeout: hostTimeout)
let hostReady = await waitForHostInWindow(
timeout: min(hostTimeout, max(0, deadline.timeIntervalSinceNow))
)
guard hostReady else {
return .failed(.hostNotReady)
}
lastSystemStartFailure = nil
guard await start() else {
guard await prepareControllerForStart() else {
stopFramePump()
if lastSystemStartFailure != nil {
return .failed(.systemRejected)
@@ -182,17 +198,49 @@ final class FlowPictureInPictureController: NSObject {
return .failed(hasHostView ? .notPossible : .hostNotReady)
}
let deadline = Date().addingTimeInterval(activeTimeout)
while Date() < deadline {
if isPictureInPictureActive { return .started }
if pipController?.isPictureInPictureActive == true {
isPictureInPictureActive = true
return .started
}
if let pipController, pipController.isPictureInPicturePossible {
pipController.startPictureInPicture()
} else {
if pipController?.isPictureInPicturePossible == true {
break
}
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()
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)
}
@@ -213,16 +261,24 @@ final class FlowPictureInPictureController: NSObject {
failure = .timedOut
}
FlowDiagnostics.log(
"PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true)"
"PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true) "
+ "requests=\(startRequestCount)"
)
stop()
return .failed(failure)
}
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
stopFramePump()
pipController?.stopPictureInPicture()
let controller = pipController
controller?.delegate = nil
controller?.stopPictureInPicture()
if !usesLowPowerVideoCallPiP {
displayLayer.sampleBufferRenderer.flush(
removingDisplayedImage: true,
@@ -232,6 +288,10 @@ final class FlowPictureInPictureController: NSObject {
isPictureInPictureActive = false
animationStartedAt = nil
lastSystemStartFailure = nil
pipController = nil
videoCallContentController = nil
transparentContentView = nil
didActivateAudioSessionBeforeController = false
isStoppingProgrammatically = false
}
@@ -277,6 +337,32 @@ final class FlowPictureInPictureController: NSObject {
/// Set once we successfully activate audio before building the controller.
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
private func activateAudioSessionForPiP() async -> Bool {
// The first PiP start uses playback. After capture establishes a
@@ -289,6 +375,24 @@ final class FlowPictureInPictureController: NSObject {
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 {
if isHostInWindowHierarchy { return true }
let deadline = Date().addingTimeInterval(timeout)
@@ -341,7 +445,9 @@ final class FlowPictureInPictureController: NSObject {
controller.canStartPictureInPictureAutomaticallyFromInline = true
controller.requiresLinearPlayback = true
pipController = controller
generation &+= 1
didActivateAudioSessionBeforeController = true
FlowDiagnostics.log("PiP controller generation \(generation) configured")
}
private func releaseAudioSessionForLowPowerPiP() {
@@ -642,6 +748,7 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
guard pictureInPictureController === pipController else { return }
isPictureInPictureActive = true
lastSystemStartFailure = nil
releaseAudioSessionForLowPowerPiP()
@@ -650,16 +757,18 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
guard pictureInPictureController === pipController else { return }
isPictureInPictureActive = false
stopFramePump()
guard !isStoppingProgrammatically else { return }
onUserDismissed?()
onUnexpectedStop?()
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
guard pictureInPictureController === pipController else { return }
// Sample-buffer fallback may need warm-up retries. VideoCall PiP uses
// the automatic-inline path plus the bounded startAndWait fallback.
lastSystemStartFailure = error
+440 -88
View File
@@ -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
final class FlowSessionManager: ObservableObject {
private struct PrefilledAIRequest {
@@ -45,12 +106,14 @@ final class FlowSessionManager: ObservableObject {
@Published private(set) var sessionExpiresAt: Date?
/// Non-nil when continuous capture failed or permissions are missing.
@Published private(set) var sessionWarning: String?
@Published private(set) var pipLifecycleState: FlowPiPLifecycleState = .inactive
private let capture = FlowContinuousCapture()
private let pipController = FlowPictureInPictureController()
private let pipController: any FlowPictureInPictureControlling
private let store = AppGroupStore()
private let analyticsClient: any AnalyticsClient
/// 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.
private let aiConversations = AIConversationStore()
/// Cached ASR instance. The only on-device backend is iOS
@@ -98,6 +161,7 @@ final class FlowSessionManager: ObservableObject {
private var pendingSourceHistoryEntryID: UUID?
private var pendingSourceHistoryEntryRevision: Int64?
private var pendingAIConversationID: UUID?
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
private var pendingProcessingDeadlineAt: TimeInterval?
private var pendingStopUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
@@ -110,6 +174,7 @@ final class FlowSessionManager: ObservableObject {
@Published private(set) var isUtteranceProcessing = false
private var finalizeTask: Task<Void, Never>?
private var asrTask: Task<Void, Never>?
private var transcriptionAnalyticsOperation: (any AnalyticsAIOperation)?
private var utteranceSafetyTask: Task<Void, Never>?
private var chunkedPipeline: ChunkedUtterancePipeline?
private var currentPartial = ""
@@ -131,7 +196,10 @@ final class FlowSessionManager: ObservableObject {
private var isAppForeground = false
/// True while handling a keyboard-initiated `startflow` cold start.
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 {
isStarting
|| startingUtteranceId != nil
@@ -148,7 +216,13 @@ final class FlowSessionManager: ObservableObject {
/// recreate the `@StateObject`-owned manager within the same process).
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
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here after a force-quit that would resurrect capture
@@ -194,10 +268,9 @@ final class FlowSessionManager: ObservableObject {
kind: .recognitionInterrupted
)
}
pipController.onUserDismissed = { [weak self] in
guard let self, self.isActive else { return }
self.debug("PiP dismissed by user — ending Flow session")
self.endSession()
pipController.onUnexpectedStop = { [weak self] in
guard let self, self.wantsActiveSession else { return }
self.handleUnexpectedPiPStop()
}
FlowTerminationCoordinator.register(self)
}
@@ -257,25 +330,25 @@ final class FlowSessionManager: ObservableObject {
}
if isActive {
wantsActiveSession = true
extendSession(duration: duration)
refreshHostReady()
if pipController.isPictureInPictureActive {
pipLifecycleState = .active
refreshHostReady()
} else {
ensurePiPActive(
trigger: coldStart ? .coldStart : .existingSession,
duration: duration
)
}
return
}
guard !isStarting else {
traceState("startSession.ignored", extra: "reason=alreadyStarting")
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
startTask?.cancel()
startTask = Task { @MainActor [weak self] in
await self?.startSessionAsync(duration: duration)
self?.handleColdStartAfterSessionReady()
}
wantsActiveSession = true
ensurePiPActive(
trigger: coldStart ? .coldStart : .foreground,
duration: duration
)
}
/// 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() {
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
isColdStartHandoff = false
if isActive {
refreshHostReady()
@@ -360,9 +443,10 @@ final class FlowSessionManager: ObservableObject {
)
}
wantsActiveSession = false
pipRecoveryOperation &+= 1
pipLifecycleState = .inactive
isColdStartHandoff = false
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
startTask?.cancel()
startTask = nil
startupAudioHealthTask?.cancel()
@@ -433,7 +517,7 @@ final class FlowSessionManager: ObservableObject {
}
func endSession() {
guard isActive else { return }
guard isActive || isStarting || wantsActiveSession else { return }
debug("Flow session ended")
if isUtteranceRecording || isUtteranceProcessing,
claimTerminal(utteranceId: currentUtteranceId) {
@@ -444,9 +528,10 @@ final class FlowSessionManager: ObservableObject {
)
}
wantsActiveSession = false
pipRecoveryOperation &+= 1
pipLifecycleState = .inactive
isColdStartHandoff = false
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
startTask?.cancel()
startTask = nil
startupAudioHealthTask?.cancel()
@@ -493,6 +578,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
isActive = false
isStarting = false
sessionExpiresAt = nil
sessionWarning = nil
currentPartial = ""
@@ -516,19 +602,27 @@ final class FlowSessionManager: ObservableObject {
case .active:
setAppForeground(true)
resumeAfterForeground()
if wantsActiveSession, !pipController.isPictureInPictureActive {
ensurePiPActive(trigger: .foreground, duration: nil)
}
case .inactive:
setAppForeground(false)
writeHeartbeatIfActive()
if isActive {
if isActive, pipController.isPictureInPictureActive {
Task { @MainActor [weak self] in
await self?.pipController.prepareForBackgroundAutoStart()
}
} else if wantsActiveSession {
pausePiPRecoveryUntilForeground()
}
case .background:
setAppForeground(false)
if isActive {
if isActive, pipController.isPictureInPictureActive {
Task { @MainActor [weak self] in
await self?.pipController.prepareForBackgroundAutoStart()
}
} else if wantsActiveSession {
pausePiPRecoveryUntilForeground()
}
if isColdStartHandoff {
completeColdStartHandoff()
@@ -549,6 +643,33 @@ final class FlowSessionManager: ObservableObject {
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() {
guard isActive else {
return
@@ -723,36 +844,192 @@ final class FlowSessionManager: ObservableObject {
// MARK: - Session start
private func startSessionAsync(duration: TimeInterval?) async {
// `isStarting` was claimed synchronously in `startSession()`.
defer { isStarting = false }
private func ensurePiPActive(
trigger: FlowPiPActivationTrigger,
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 }
traceState("startSessionAsync.begin")
traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)")
sessionWarning = nil
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
pipLifecycleState = .failed(.notPossible)
traceState("startSessionAsync.blocked", extra: "reason=permissions")
FlowSessionBridge.setHostReady(false)
isColdStartHandoff = false
return
}
switch await pipController.startAndWait() {
case .started:
activateFlowSessionAfterPiPProof(duration: duration)
traceState("startSessionAsync.ready")
debug("Flow session started (PiP keep-alive), mic released between utterances")
case .failed(let failure):
let message = AppL10n.string(failure.localizationKey)
sessionWarning = message
traceState("startSessionAsync.failed", extra: "reason=pipUnavailable failure=\(failure)")
FlowSessionBridge.setHostReady(false)
if isColdStartHandoff {
scheduleColdStartRecovery(duration: duration)
let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
var lastFailure: FlowPiPStartFailure = .timedOut
for attempt in 1...FlowPiPRecoveryPolicy.maxAttempts {
guard FlowPiPRecoveryPolicy.canContinue(
operation: operation,
currentOperation: pipRecoveryOperation,
wantsActiveSession: wantsActiveSession,
isAppForeground: isAppForeground,
taskIsCancelled: Task.isCancelled
) else {
if operation == pipRecoveryOperation,
wantsActiveSession,
!Task.isCancelled,
!isAppForeground {
pipLifecycleState = .waitingForForeground
FlowSessionBridge.setHostReady(false)
}
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)"
)
}
debug("PiP keep-alive failed to start: \(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?) {
@@ -762,6 +1039,8 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId)
FlowSessionDarwin.postSessionChanged()
isActive = true
wantsActiveSession = true
pipLifecycleState = .active
// Low-profile PiP is a system-owned keep-alive surface. Keeping the
// display awake wastes far more power than the static PiP itself.
ScreenWakeLock.release()
@@ -862,18 +1141,10 @@ final class FlowSessionManager: ObservableObject {
guard isColdStartHandoff, isActive else { return }
sessionWarning = nil
if !pipController.isPictureInPictureActive {
switch await pipController.startAndWait() {
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
}
ensurePiPActive(trigger: .existingSession, duration: nil)
return
}
pipLifecycleState = .active
refreshHostReady()
handleColdStartAfterSessionReady()
}
@@ -895,7 +1166,9 @@ final class FlowSessionManager: ObservableObject {
let message = AppL10n.string("flow.session.error.notPossible")
sessionWarning = message
FlowSessionBridge.setHostReady(false)
scheduleColdStartRecovery(duration: nil)
if !pipController.isPictureInPictureActive {
scheduleColdStartRecovery(duration: nil)
}
debug("cold-start blocked: host ready contract not published")
return
}
@@ -927,31 +1200,14 @@ final class FlowSessionManager: ObservableObject {
/// bounce the audio session and rebuild. Force-quit relaunches routinely
/// inherit stale mediaserverd state that only a rebuild clears.
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
// scene and Pegasus service a short settling interval before the
// one bounded cold-start retry creates a new controller generation.
try? await Task.sleep(nanoseconds: 400_000_000)
guard !Task.isCancelled, self.isColdStartHandoff else { return }
let outcome = await self.pipController.startAndWait()
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
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)
}
}
// The failed controller has been fully torn down. Give the app
// scene and Pegasus service a short settling interval before the
// one bounded cold-start retry creates a new controller generation.
ensurePiPActive(
trigger: .startupRecovery,
duration: duration,
forceReset: true
)
}
private func bindSessionASRIfNeeded(force: Bool = false) {
@@ -1245,6 +1501,7 @@ final class FlowSessionManager: ObservableObject {
)
)
currentUtteranceMode = command.resolvedUtteranceMode
pendingManagedRequestPurpose = command.managedRequestPurpose
pendingAIConversationID = currentUtteranceMode == .aiQuestion
? command.aiConversationID
: nil
@@ -1357,6 +1614,7 @@ final class FlowSessionManager: ObservableObject {
commandSeq: command.commandSeq
) else { return }
currentUtteranceMode = .aiQuestion
pendingManagedRequestPurpose = command.managedRequestPurpose
pendingAIConversationID = command.aiConversationID
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
@@ -1435,7 +1693,9 @@ final class FlowSessionManager: ObservableObject {
store: pipelineStore,
conversations: aiConversations,
taskKind: request.taskKind,
thinkingEnabled: request.thinkingEnabled
thinkingEnabled: request.thinkingEnabled,
analyticsClient: analyticsClient,
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
@@ -1610,6 +1870,13 @@ final class FlowSessionManager: ObservableObject {
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
status: FlowResult.Status = .error
) {
finishTranscriptionAnalytics(
success: false,
failureCategory: Self.transcriptionFailureCategory(
kind: kind,
status: status
)
)
guard let activeSessionId, let currentUtteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
@@ -1901,6 +2168,11 @@ final class FlowSessionManager: ObservableObject {
)
let cloudASRForStreaming = useStreaming ? cloudService : nil
transcriptionAnalyticsOperation?.cancel()
transcriptionAnalyticsOperation = analyticsClient.startAIFeature(
.transcription,
executionMode: analyticsExecutionMode
)
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
let outcome: ChunkedUtterancePipelineOutcome
@@ -2220,6 +2492,7 @@ final class FlowSessionManager: ObservableObject {
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
pendingAIConversationID = nil
pendingManagedRequestPurpose = nil
pendingProcessingDeadlineAt = nil
currentUtteranceMode = .dictation
}
@@ -2237,6 +2510,7 @@ final class FlowSessionManager: ObservableObject {
let sourceHistoryEntryID = pendingSourceHistoryEntryID
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
let aiConversationID = pendingAIConversationID
let managedRequestPurpose = pendingManagedRequestPurpose
let processingDeadlineAt = pendingProcessingDeadlineAt
let asrEngineMode = sessionASREngineMode ?? store.engineMode
// ALWAYS clear the processing gate for this utterance. The previous
@@ -2250,6 +2524,7 @@ final class FlowSessionManager: ObservableObject {
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
pendingAIConversationID = nil
pendingManagedRequestPurpose = nil
pendingProcessingDeadlineAt = nil
if currentUtteranceMode == utteranceMode {
currentUtteranceMode = .dictation
@@ -2366,6 +2641,7 @@ final class FlowSessionManager: ObservableObject {
)
return
}
finishTranscriptionAnalytics(success: true)
// Re-read App Group at finalize so dictionary and translation changes
// from the keyboard extension are visible before local correction,
@@ -2427,7 +2703,9 @@ final class FlowSessionManager: ObservableObject {
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations,
taskKind: .aiQuestion
taskKind: .aiQuestion,
analyticsClient: analyticsClient,
analyticsFeature: .aiAssistant
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let publishUtteranceId = finalizeUtteranceId
@@ -2564,6 +2842,7 @@ final class FlowSessionManager: ObservableObject {
systemPrompt: instructionPrompt?.system,
providerIdOverride: pipelineStore.polishProviderIdOverride,
taskKind: isEditLastInput ? .editLastInput : nil,
requestPurpose: managedRequestPurpose,
context: isInstructionMode ? nil : polishContext,
timeoutLimit: processingDeadlineAt.map {
max(0.1, $0 - Date().timeIntervalSince1970)
@@ -2868,6 +3147,13 @@ final class FlowSessionManager: ObservableObject {
status: FlowResult.Status = .error,
aiConversationID: UUID? = nil
) {
finishTranscriptionAnalytics(
success: false,
failureCategory: Self.transcriptionFailureCategory(
kind: kind,
status: status
)
)
guard let sessionId, let utteranceId else { return }
FlowTrace.warn(
"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 func resultRevision() -> Int64 {
@@ -3030,6 +3369,7 @@ final class FlowSessionManager: ObservableObject {
systemPrompt: String? = nil,
providerIdOverride: String?,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext?,
timeoutLimit: TimeInterval? = nil
) async throws -> PolishingService.PolishOutcome {
@@ -3042,6 +3382,7 @@ final class FlowSessionManager: ObservableObject {
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: context
)
}
@@ -3081,6 +3422,14 @@ final class FlowSessionManager: ObservableObject {
self.isUtteranceRecording || self.isUtteranceProcessing {
await self.reactivateCaptureIfNeeded()
}
if self.wantsActiveSession,
!self.pipController.isPictureInPictureActive {
if self.isAppForeground {
self.ensurePiPActive(trigger: .healthCheck, duration: nil)
} else {
self.pipLifecycleState = .waitingForForeground
}
}
FlowSessionBridge.writeHeartbeat()
self.refreshHostReady()
try? await Task.sleep(nanoseconds: 1_000_000_000)
@@ -3123,6 +3472,9 @@ final class FlowSessionManager: ObservableObject {
"event=\(event)",
"active=\(isActive)",
"starting=\(isStarting)",
"wantsActive=\(wantsActiveSession)",
"pipState=\(String(describing: pipLifecycleState))",
"pipGeneration=\(pipController.generation)",
"coldStart=\(isColdStartHandoff)",
"sessionId=\(sessionId)",
"utteranceId=\(utteranceId)",
@@ -505,6 +505,7 @@ private struct AccountCreditsSection: View {
private struct AccountCreditPurchaseSection: View {
@Environment(\.themePalette) private var palette
@ObservedObject var manager: AccountCreditPurchaseManager
@State private var didRecordPurchaseView = false
let accountID: UUID
let onGranted: () -> Void
@@ -540,6 +541,11 @@ private struct AccountCreditPurchaseSection: View {
guard current != nil, current != previous else { return }
onGranted()
}
.onAppear {
guard !didRecordPurchaseView else { return }
didRecordPurchaseView = true
manager.recordPurchaseViewed()
}
}
private func purchaseOption(_ option: AccountCreditPurchaseOption) -> some View {
@@ -777,10 +783,17 @@ private struct AccountInvitationActivityView: UIViewControllerRepresentable {
let invitationURL: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(
let controller = UIActivityViewController(
activityItems: [invitationURL],
applicationActivities: nil
)
controller.completionWithItemsHandler = { _, completed, _, _ in
guard completed else { return }
Task { @MainActor in
AnalyticsHostService.shared.client.recordReferralShared()
}
}
return controller
}
func updateUIViewController(
@@ -7,6 +7,7 @@
import Combine
import Foundation
import OSGKeyboardHostSupport
import OSGKeyboardShared
struct AccountCreditPurchaseOption: Identifiable, Equatable {
var id: String { productID }
@@ -43,6 +44,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
private let service: any AccountCenterServicing
private let store: any AccountCreditStore
private let analyticsClient: any AnalyticsClient
private var updatesTask: Task<Void, Never>?
private var activeAccountID: UUID?
private var configuredProductIDs: Set<String> = []
@@ -50,10 +52,12 @@ final class AccountCreditPurchaseManager: ObservableObject {
init(
service: any AccountCenterServicing,
store: any AccountCreditStore = LiveAccountCreditStore()
store: any AccountCreditStore = LiveAccountCreditStore(),
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
) {
self.service = service
self.store = store
self.analyticsClient = analyticsClient
}
deinit {
@@ -100,6 +104,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
return false
}
state = .purchasing(productID: productID)
analyticsClient.recordPurchaseStarted()
do {
switch try await store.purchase(productID: productID, accountID: accountID) {
case .success(let verification):
@@ -109,6 +114,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
state = .pending
case .userCancelled:
state = .idle
analyticsClient.recordPurchaseCancelled()
@unknown default:
state = .failed(messageKey: "account.storekit.error.unknown")
}
@@ -118,6 +124,10 @@ final class AccountCreditPurchaseManager: ObservableObject {
return false
}
func recordPurchaseViewed() {
analyticsClient.recordPurchaseViewed()
}
func clearTransientState() {
switch state {
case .succeeded, .failed, .pending:
@@ -6,6 +6,7 @@
import Combine
import Foundation
import OSGKeyboardShared
@MainActor
final class AccountSessionCoordinator: ObservableObject {
@@ -46,6 +47,9 @@ final class AccountSessionCoordinator: ObservableObject {
private let sessionService: any AccountSessionServicing
private let centerService: any AccountCenterServicing
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 now: () -> Date
private var didAttemptRestore = false
@@ -57,15 +61,22 @@ final class AccountSessionCoordinator: ObservableObject {
pendingReferralStore: any PendingReferralCodeStoring =
UserDefaultsPendingReferralCodeStore(),
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
centerService = dependencies.centerService
creditPurchases = AccountCreditPurchaseManager(
service: dependencies.centerService,
store: creditStore
store: creditStore,
analyticsClient: analyticsClient
)
self.pendingReferralStore = pendingReferralStore
self.analyticsClient = analyticsClient
self.onAccountAuthenticated = onAccountAuthenticated
self.onAccountDeleted = onAccountDeleted
self.accountRefreshInterval = accountRefreshInterval
self.now = now
pendingReferralCode = pendingReferralStore.code
@@ -94,6 +105,7 @@ final class AccountSessionCoordinator: ObservableObject {
sessionPhase = .signedOut
return
}
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
@@ -114,6 +126,7 @@ final class AccountSessionCoordinator: ObservableObject {
pendingReferralCode = code
shouldPresentAccountCenter = true
operationErrorKey = nil
analyticsClient.recordInviteOpened()
if isSignedIn {
Task {
@@ -138,6 +151,7 @@ final class AccountSessionCoordinator: ObservableObject {
do {
let session = try await sessionService.signIn(with: payload)
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
@@ -260,6 +274,7 @@ final class AccountSessionCoordinator: ObservableObject {
do {
try await sessionService.deleteAccount(with: payload)
await onAccountDeleted()
creditPurchases.reset()
clearAccountRefreshState()
sessionPhase = .signedOut
@@ -22,6 +22,9 @@ enum LiveAccountDependencyFactory {
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
let apiClient = AccountAPIClient(sessionVault: sessionVault)
HostAnalyticsBearerBridge.shared.install(
AccountAnalyticsBearerProvider(apiClient: apiClient)
)
let integrity = DeviceIntegrityCoordinator(
apiClient: apiClient,
keyStateStore: sessionVault
+130 -51
View File
@@ -14,6 +14,68 @@ private enum HomeRoute: Hashable {
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 {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@@ -55,6 +117,23 @@ struct HomeView: View {
!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 {
!keyboardHintDismissed
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
@@ -125,11 +204,11 @@ struct HomeView: View {
.padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding)
if showsFlowSessionExtras {
flowSessionExtras
.padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
}
// Engine + Flow status now stays directly below the
// logo so startup and recovery are always visible.
scrollStatusFooter
.padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
@@ -138,11 +217,6 @@ struct HomeView: View {
homeLibrarySection
.padding(.horizontal, Spacing.lg)
.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)
.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 {
VStack(spacing: Spacing.xs) {
setupGuidanceCard {
engineStatusLine
flowStatusFooter
Divider()
flowSessionExtras
}
.frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)
}
// MARK: - Wide layout (iPad / regular width)
@@ -169,16 +243,11 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader
if showsFlowSessionExtras {
flowSessionExtras
}
scrollStatusFooter
HomeUsageStatsSection(layout: .split)
homeLibrarySection
scrollStatusFooter
.frame(maxWidth: .infinity)
}
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
@@ -466,7 +535,26 @@ struct HomeView: View {
if needsAPIKeySetup {
// API Key
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 {
flowManager.endSession()
} label: {
@@ -477,8 +565,8 @@ struct HomeView: View {
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
} else if canManuallyStartSession {
// Sessions no longer auto-start on foreground (keyboard survival).
// After a manual stop / expiry, Start is the explicit re-entry.
// Foreground activation is automatic. After an explicit stop
// while staying on this screen, Start is the explicit re-entry.
Button {
flowManager.activateOnForeground(
reason: "HomeView.startButton",
@@ -498,18 +586,10 @@ struct HomeView: View {
// MARK: - Flow extras (warnings / hints)
private var showsFlowSessionExtras: Bool {
needsPermissionSetup
|| flowManager.sessionWarning != nil
|| needsAPIKeySetup
|| shouldShowKeyboardHint
|| !flowManager.isActive
}
@ViewBuilder
private var flowSessionExtras: some View {
if needsPermissionSetup {
setupGuidanceCard {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(AppPermissions.homePermissionGuidanceMessage)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
@@ -526,14 +606,14 @@ struct HomeView: View {
.buttonStyle(.plain)
}
} else if let warning = flowManager.sessionWarning {
setupGuidanceCard {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(warning)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.fixedSize(horizontal: false, vertical: true)
}
} else if needsAPIKeySetup {
setupGuidanceCard {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(config.isLocalEngine
? "home.setup.polishKeyMissing"
: "home.setup.cloudIncomplete")
@@ -542,7 +622,7 @@ struct HomeView: View {
.fixedSize(horizontal: false, vertical: true)
}
} else if shouldShowKeyboardHint {
setupGuidanceCard {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("home.setup.keyboardHint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
@@ -584,6 +664,8 @@ struct HomeView: View {
if needsAPIKeySetup { return palette.warning }
if flowManager.isUtteranceRecording { 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.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning }
@@ -598,23 +680,20 @@ struct HomeView: View {
/// with the OS), so the previous downloading / warming / failed
/// states collapse into the cloud-engine branch.
private var flowCapsuleStatusMessage: String {
if flowManager.isStarting {
return AppL10n.string("home.flow.starting")
let descriptor = FlowHomePiPStatusPolicy.descriptor(
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 {
+28 -3
View File
@@ -16,15 +16,30 @@ struct MainAppRoot: View {
// `@ObservedObject` keeps subscriptions correct across Settings replay.
@ObservedObject private var config = ProviderConfig.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
@State private var clmWarmupTask: Task<Void, Never>?
@State private var rimeStartupTask: Task<Void, Never>?
@State private var firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown
init(accountDependencies: AccountDependencies? = nil) {
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
let analytics = AnalyticsHostService.shared
_flowManager = StateObject(
wrappedValue: FlowSessionManager(analyticsClient: analytics.client)
)
_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)
.environmentObject(flowManager)
.environmentObject(accountSession)
.environmentObject(analytics)
.background {
FlowPiPHostView { view in
flowManager.attachPiPHostView(view)
@@ -65,6 +81,9 @@ struct MainAppRoot: View {
AppOpenURLRouter.shared.register { url in
handleIncomingURL(url)
}
analytics.prepare(
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
)
OSGDiag.log(
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
@@ -131,6 +150,11 @@ struct MainAppRoot: View {
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
if phase == .active {
analytics.appDidBecomeActive()
} else if phase == .background {
analytics.appDidEnterBackground()
}
guard phase == .active else {
clmWarmupTask?.cancel()
clmWarmupTask = nil
@@ -200,7 +224,7 @@ struct MainAppRoot: View {
MainTabView()
.id("main")
} else {
OnboardingView(config: config)
OnboardingExperienceView(config: config)
.id("onboarding")
}
}
@@ -246,6 +270,7 @@ struct MainAppRoot: View {
private func handleIncomingURL(_ url: URL) {
if accountSession.handleIncomingURL(url) {
firstOpenAcquisitionChannel = .referral
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
return
}
File diff suppressed because it is too large Load Diff
@@ -565,11 +565,35 @@ struct ClipboardSettingsView: View {
struct AboutSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.openURL) private var openURL
@EnvironmentObject private var analytics: AnalyticsHostService
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
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") {
VStack(spacing: 0) {
Button {
@@ -637,5 +661,8 @@ struct AboutSettingsView: View {
.navigationTitle("settings.about.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.onAppear {
analytics.refreshEnabledState()
}
}
}
+60
View File
@@ -36,6 +36,58 @@
"onboarding.polish.title" = "Text polish (LLM)";
"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.experience.intro.eyebrow" = "AI AGENT VOICE KEYBOARD";
"onboarding.experience.intro.title" = "Speak. Its 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" = "“Ill arrive tomorrow afternoon and message you when Im there.”";
"onboarding.experience.practice.editorTitle" = "New Message";
"onboarding.experience.practice.permissionsTitle" = "Voice access is still needed";
"onboarding.experience.practice.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" = "Thats it.";
"onboarding.experience.complete.title" = "Youre ready";
"onboarding.experience.complete.subtitle" = "Switch to OSGKeyboard in any text field to speak, write, or hand off the next step to your AI Agent.";
"onboarding.experience.complete.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";
/* Common navigation */
@@ -419,6 +471,11 @@
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
"home.setup.keyboardHint.dismiss" = "Got it";
"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.start" = "Start voice session";
"home.flow.end" = "End voice session";
@@ -805,3 +862,6 @@
"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.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.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
"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" = "重新开始权限引导";
/* Common navigation */
@@ -418,6 +470,11 @@
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
"home.setup.keyboardHint.dismiss" = "知道了";
"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.start" = "启动语音会话";
"home.flow.end" = "结束语音会话";
@@ -804,3 +861,6 @@
"account.managed.accountCredits" = "账号积分";
"account.managed.explanation" = "该选择与本地或云端语音识别相互独立。账号会话仅保存在主 App,键盘只能获得短时、受限的网关授权。";
"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) {
super.viewWillDisappear(animated)
AnalyticsExtensionService.shared.keyboardWillDisappear()
assistantFieldActionRefreshTask?.cancel()
assistantFieldActionRefreshTask = nil
clipboardCapture?.keyboardWillDisappear()
@@ -305,6 +306,9 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
AnalyticsExtensionService.shared.recordPresentation(
hasFullAccess: hasFullAccess
)
OSGDiag.log(
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
+38 -1
View File
@@ -5,7 +5,44 @@
<key>NSPrivacyTracking</key>
<false/>
<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>
<array>
<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
// App Group transiently reads empty.
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive
}
func persistLocale(_ id: String) {
@@ -125,6 +125,10 @@ final class KeyboardFlowCoordinator {
|| currentUtteranceRequest != nil
}
private var voiceSetupReady: Bool {
state.hasCompletedOnboarding || state.isOnboardingPracticeActive
}
var isEditSessionActive: Bool { currentUtteranceRequest?.isEdit == true }
/// 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).
func ensurePiPReadyOnKeyboardOpen() {
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
state.hasCompletedOnboarding,
voiceSetupReady,
hasFullAccess(),
AppGroup.isAvailable,
!isPendingFlowStart,
@@ -335,7 +339,7 @@ final class KeyboardFlowCoordinator {
appGroupAvailable: AppGroup.isAvailable,
hostReady: hostReady,
isPreparingSession: isPendingFlowStart || hostWarming,
hasCompletedOnboarding: state.hasCompletedOnboarding
hasCompletedOnboarding: voiceSetupReady
)
let signature = [
"phase=\(String(describing: state.phase))",
@@ -1070,7 +1074,7 @@ final class KeyboardFlowCoordinator {
}
func beginFlowStart(recordAfterHandoff: Bool = false) {
guard state.hasCompletedOnboarding else {
guard voiceSetupReady else {
promptFinishSetupInApp()
return
}
@@ -1177,6 +1181,8 @@ final class KeyboardFlowCoordinator {
let mode: FlowUtteranceMode? = request.mode == .dictation
? nil
: request.mode
let managedRequestPurpose = request.managedRequestPurpose
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
@@ -1194,6 +1200,7 @@ final class KeyboardFlowCoordinator {
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
aiConversationID: request.aiConversationID,
aiTaskKind: request.aiTaskKind,
managedRequestPurpose: managedRequestPurpose,
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
processingDeadlineAt: action == .stopRecording && request.isEdit
? Date().timeIntervalSince1970
@@ -88,6 +88,7 @@ final class KeyboardTextInserter {
)
let inserted = separator + trimmed
insertText(inserted)
KeyboardSetupBridge.markVoiceInsertion()
state.noteUserDidInputText()
recordLastInsertion(
inserted,
+9 -1
View File
@@ -292,7 +292,15 @@ struct AIKeyboardView: View {
@ViewBuilder
private var contextArea: some View {
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) {
Text(tip)
.font(TypeStyle.body)
+1
View File
@@ -14,6 +14,7 @@
"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.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.back" = "Back";
@@ -14,6 +14,7 @@
"onboarding.api.subtitle" = "本地识别无需 Key;填写 API Key 后可开启 AI 润色。";
"onboarding.api.localReady.title" = "本地识别已就绪";
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
/* Common navigation */
"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 })
}
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() {
XCTAssertEqual(
ManagedGatewayTaskKind.allCases.map(\.rawValue),
@@ -40,7 +40,10 @@ public protocol ConfigurationStore: Sendable {
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
var cloudASRPersistence: UserDefaults { get }
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient
func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
) -> LLMClient
}
public extension ConfigurationStore {
@@ -48,6 +51,10 @@ public extension ConfigurationStore {
var credentialSource: CredentialSource { .byok }
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 cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence }
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
public func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
) -> LLMClient {
if credentialSource == .managed {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
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"
}
/// 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 static let maximumAccessLifetime: TimeInterval = 5 * 60
@@ -143,4 +149,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
let temperature: Double
let stream: Bool
let taskKind: ManagedGatewayTaskKind
let requestPurpose: ManagedGatewayRequestPurpose?
}
@@ -50,6 +50,7 @@ public struct ManagedLLMClient: LLMClient {
public let capability: Capability
public let taskKind: ManagedGatewayTaskKind
public let requestPurpose: ManagedGatewayRequestPurpose?
public let requestTimeout: TimeInterval
private let baseURL: URL
@@ -60,6 +61,7 @@ public struct ManagedLLMClient: LLMClient {
public init(
capability: Capability,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared,
@@ -68,6 +70,7 @@ public struct ManagedLLMClient: LLMClient {
) {
self.capability = capability
self.taskKind = taskKind ?? capability.defaultTaskKind
self.requestPurpose = requestPurpose
self.grants = grants
self.baseURL = baseURL
self.session = session
@@ -287,7 +290,8 @@ public struct ManagedLLMClient: LLMClient {
maxOutputTokens: min(max(attempt.options.maxTokens ?? 512, 1), 4_096),
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
stream: stream,
taskKind: taskKind
taskKind: taskKind,
requestPurpose: requestPurpose
)
var request = URLRequest(
@@ -419,6 +423,7 @@ public struct ManagedLLMClient: LLMClient {
public enum ManagedGatewayLLMClientFactory {
public static func polish(
taskKind: ManagedGatewayTaskKind = .dictationPolish,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
@@ -426,6 +431,7 @@ public enum ManagedGatewayLLMClientFactory {
ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
grants: grants,
baseURL: baseURL,
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 {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
grants: GatewayGrantCoordinator()
)
}
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
case submitAIQuestion
}
/// Wire version that includes managed-gateway AI task intent.
public static let currentProtocolVersion = 6
/// Wire version that includes managed-gateway request purpose.
public static let currentProtocolVersion = 7
public let protocolVersion: Int
public let sessionId: UUID
@@ -43,6 +43,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiQuestionText: String?
/// Fine-grained managed-gateway intent for AI question submissions.
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).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
@@ -65,6 +67,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
@@ -84,6 +87,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.managedRequestPurpose = managedRequestPurpose
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
@@ -15,6 +15,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiQuestionText: String?
/// Fine-grained managed-gateway intent. Regular questions keep the default.
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).
public let aiThinkingEnabled: Bool?
@@ -28,6 +30,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
@@ -37,6 +40,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.managedRequestPurpose = managedRequestPurpose
self.aiThinkingEnabled = aiThinkingEnabled
}
@@ -114,22 +114,33 @@ public struct AIQuestionService: Sendable {
private let client: any LLMClient
private let conversations: AIConversationStore
private let responseLength: AIResponseLength
private let analyticsClient: any AnalyticsClient
private let analyticsFeature: AnalyticsFeature
private let analyticsExecutionMode: AnalyticsExecutionMode
public init(
client: any LLMClient,
conversations: AIConversationStore,
responseLength: AIResponseLength = .default
responseLength: AIResponseLength = .default,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
analyticsFeature: AnalyticsFeature = .aiAssistant,
analyticsExecutionMode: AnalyticsExecutionMode = .byok
) {
self.client = client
self.conversations = conversations
self.responseLength = responseLength
self.analyticsClient = analyticsClient
self.analyticsFeature = analyticsFeature
self.analyticsExecutionMode = analyticsExecutionMode
}
public static func configured(
store: any ConfigurationStore,
conversations: AIConversationStore,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool = true
thinkingEnabled: Bool = true,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
analyticsFeature: AnalyticsFeature = .aiAssistant
) throws -> AIQuestionService {
if store.credentialSource == .managed {
return AIQuestionService(
@@ -139,7 +150,10 @@ public struct AIQuestionService: Sendable {
grants: GatewayGrantCoordinator()
),
conversations: conversations,
responseLength: store.aiResponseLength
responseLength: store.aiResponseLength,
analyticsClient: analyticsClient,
analyticsFeature: analyticsFeature,
analyticsExecutionMode: .managed
)
}
// Same provider + baseURL + model resolution as dictation polish so the
@@ -172,7 +186,10 @@ public struct AIQuestionService: Sendable {
thinkingEnabled: thinkingEnabled
),
conversations: conversations,
responseLength: store.aiResponseLength
responseLength: store.aiResponseLength,
analyticsClient: analyticsClient,
analyticsFeature: analyticsFeature,
analyticsExecutionMode: .byok
)
}
@@ -199,28 +216,38 @@ public struct AIQuestionService: Sendable {
maxTokens: Self.outputTokenLimit
)
var accumulated = ""
for try await event in client.completeStreaming(
messages: messages,
timeout: Self.requestTimeout,
options: options
) {
try Task.checkCancellation()
switch event {
case .delta(let chunk):
accumulated += chunk
let preview = Self.streamingPreview(accumulated)
onPartial?(preview)
case .restart:
accumulated = ""
onPartial?("")
let operation = analyticsClient.startAIFeature(
analyticsFeature,
executionMode: analyticsExecutionMode
)
do {
var accumulated = ""
for try await event in client.completeStreaming(
messages: messages,
timeout: Self.requestTimeout,
options: options
) {
try Task.checkCancellation()
switch event {
case .delta(let chunk):
accumulated += chunk
let preview = Self.streamingPreview(accumulated)
onPartial?(preview)
case .restart:
accumulated = ""
onPartial?("")
}
}
}
try Task.checkCancellation()
try Task.checkCancellation()
let answer = Self.boundedAnswer(accumulated)
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
return answer
let answer = Self.boundedAnswer(accumulated)
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
operation.succeed()
return answer
} catch {
operation.fail(category: Self.analyticsFailureCategory(for: error))
throw error
}
}
/// Commit only after the host wins the utterance terminal claim. Keeping
@@ -254,6 +281,42 @@ public struct AIQuestionService: Sendable {
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.
public static func streamingPreview(_ value: String) -> String {
if value.count <= AIQuestionLimits.maximumAnswerCharacterCount {
@@ -394,7 +394,13 @@ public struct AppGroupStore: @unchecked Sendable {
// MARK: - Client
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
configuration.makeClient(taskKind: taskKind)
public func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
) -> LLMClient {
configuration.makeClient(
taskKind: taskKind,
requestPurpose: requestPurpose
)
}
}
@@ -11,6 +11,8 @@ public enum KeyboardSetupBridge {
private enum Key {
static let fullAccessReady = "keyboard.extension.fullAccessReady"
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.
@@ -19,11 +21,72 @@ public enum KeyboardSetupBridge {
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.
public static func markExtensionAppearance(hasFullAccess: Bool) {
guard AppGroup.isAvailable else { return }
let defaults = AppGroup.defaults
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt)
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
/// Unified mic color / tap / hint source for the keyboard extension.
@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
/// selected but the provider-specific API key is missing).
@Published public var micDisabled: Bool = false
@@ -54,6 +54,7 @@ public actor PolishingService {
let systemPrompt: String?
let providerIdOverride: String?
let taskKind: ManagedGatewayTaskKind?
let requestPurpose: ManagedGatewayRequestPurpose?
let context: PolishContext?
}
@@ -79,6 +80,7 @@ public actor PolishingService {
private let store: any ConfigurationStore
private let timeout: TimeInterval
private let analyticsClient: any AnalyticsClient
/// Optional injected client (mostly for testing). When nil we build
/// one from `store.makeClient()` per call.
private let injectedClient: LLMClient?
@@ -91,11 +93,13 @@ public actor PolishingService {
public init(
store: any ConfigurationStore = AppGroupStore(),
client: LLMClient? = nil,
timeout: TimeInterval? = nil
timeout: TimeInterval? = nil,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
self.analyticsClient = analyticsClient
}
/// Context-aware polish entry point. The optional
@@ -110,6 +114,7 @@ public actor PolishingService {
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext? = nil
) async throws -> String {
try await performPolish(
@@ -119,6 +124,7 @@ public actor PolishingService {
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: context
)
).text
@@ -132,6 +138,7 @@ public actor PolishingService {
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext? = nil
) async throws -> PolishOutcome {
try await performPolish(
@@ -141,6 +148,7 @@ public actor PolishingService {
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: context
)
)
@@ -152,6 +160,7 @@ public actor PolishingService {
let systemPrompt = request.systemPrompt
let providerIdOverride = request.providerIdOverride
let taskKind = request.taskKind
let requestPurpose = request.requestPurpose
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
@@ -185,14 +194,26 @@ public actor PolishingService {
}
}
let remoteResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: resolvedContext
let operation = analyticsClient.startAIFeature(
.polish,
executionMode: analyticsExecutionMode
)
let remoteResult: RemotePolishResult
do {
remoteResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: resolvedContext
)
operation.succeed()
} catch {
operation.fail(category: Self.analyticsFailureCategory(for: error))
throw error
}
// Translation and custom prompts bypass the polish post-processor.
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
@@ -214,6 +235,51 @@ public actor PolishingService {
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 {
switch mode {
case .polish:
@@ -229,6 +295,7 @@ public actor PolishingService {
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext
) async throws -> RemotePolishResult {
let effectiveProviderId = Self.resolvedProviderId(
@@ -240,7 +307,8 @@ public actor PolishingService {
client = injectedClient
} else if store.credentialSource == .managed {
client = store.makeClient(
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode)
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode),
requestPurpose: requestPurpose
)
} else {
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)
}
}
+165
View File
@@ -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
}
}
+267
View File
@@ -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)
}
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
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,
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)
}
@@ -1378,7 +1378,7 @@ final class FlowSessionBridgeTests: XCTestCase {
historyEntryRevision: 9,
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)
}
+1 -1
View File
@@ -108,7 +108,7 @@ iOS / iPadOS 账号中心支持:
- **击键不上传**:中文候选学习与英文建议偏好保存在本机,密码框不学习
- **剪贴板可控**:历史默认关闭、最多 15 条、仅本机保存,不会自行发送给 AI
- **凭证隔离**:用户 API Key 保存在 Keychain;账号会话令牌只在主 App 私有 Keychain
- **无广告追踪**:不集成广告、分析或追踪 SDK,不出售个人数据
- **无广告追踪**:不集成第三方分析、广告或追踪 SDK;可退出的第一方产品分析不采集输入内容,不出售个人数据
完整数据类型、保留策略、账号删除与第三方服务说明见[隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。
+53 -14
View File
@@ -2,12 +2,13 @@
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
#
# 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:
# cold — terminate-existing launch (force-quit / cold start)
# bgfg — open Safari (background) then relaunch OSG (foreground restore)
# 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
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -15,9 +16,11 @@ cd "$ROOT"
UDID="${1:-00008130-001C249C0E52001C}"
COUNT="${2:-50}"
SUITES="${3:-cold,bgfg,hold}"
SUITES="${3:-cold,bgfg,hold,reclaim}"
BUNDLE="com.osgkeyboard.ios"
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)"
mkdir -p "$OUT_DIR"
REPORT="$OUT_DIR/report.jsonl"
@@ -48,9 +51,18 @@ console_launch() {
"$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() {
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
host="success"
@@ -93,12 +105,23 @@ classify_file() {
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() {
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"
for i in $(seq 1 "$COUNT"); do
outfile="$OUT_DIR/${suite}-$i.console.log"
@@ -112,7 +135,8 @@ run_suite() {
;;
bgfg)
# 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
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
sleep 3
@@ -120,7 +144,8 @@ run_suite() {
console_launch "$outfile" 12 ""
;;
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)
sleep 20
# Background briefly then resume
@@ -128,6 +153,15 @@ run_suite() {
sleep 2
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
elapsed=$(( $(date +%s) - start_ts ))
@@ -135,12 +169,14 @@ run_suite() {
host="${result%%|*}"
rest="${result#*|}"
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' \
"$suite" "$i" "$host" "$pip" "$mic" "$elapsed" >>"$REPORT"
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s %2ds\n" \
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$elapsed"
printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","recovery":"%s","elapsed_s":%d}\n' \
"$suite" "$i" "$host" "$pip" "$mic" "$recovery" "$elapsed" >>"$REPORT"
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s recovery=%-10s %2ds\n" \
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$recovery" "$elapsed"
done
}
@@ -148,7 +184,7 @@ run_suite() {
IFS=',' read -r -a selected_suites <<<"$SUITES"
for selected_suite in "${selected_suites[@]}"; do
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
exit 1
@@ -165,7 +201,7 @@ by = collections.defaultdict(list)
for r in rows:
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 = [
"Device PiP / mic keep-alive stress summary",
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"))
pip_c = collections.Counter(r["pip"] 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)
lines += [
f"[{suite}]",
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" mic={dict(mic_c)}",
f" recovery={dict(recovery_c)}",
"",
]
lines += [
@@ -192,6 +230,7 @@ lines += [
" - cold uses --terminate-existing (force-quit recovery).",
" - bgfg backgrounds via Safari then resumes.",
" - 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.",
]
text = "\n".join(lines) + "\n"
+15
View File
@@ -40,6 +40,17 @@
"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": {
"description": "iCloud KVS merge/sync for settings, local-only clipboard consent, dictionary, speech history, usage stats",
"platform": "ios",
@@ -123,6 +134,8 @@
"platform": "ios",
"tests": [
"OSGKeyboardTests/FlowHandoffPolicyTests",
"OSGKeyboardTests/FlowHomePiPStatusPolicyTests",
"OSGKeyboardTests/FlowPiPRecoveryPolicyTests",
"OSGKeyboardTests/FlowSessionBridgeTests",
"OSGKeyboardTests/FlowSessionPolicyTests",
"OSGKeyboardTests/FlowBudgetAndMergeTests",
@@ -207,6 +220,7 @@
"config",
"account",
"managed_gateway",
"analytics",
"sync",
"polish",
"edit_last_input",
@@ -225,6 +239,7 @@
"config",
"account",
"managed_gateway",
"analytics",
"sync",
"polish",
"edit_last_input",
+43 -2
View File
@@ -71,7 +71,11 @@ TYPING
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.
• User-configured cloud requests go directly to that provider. Managed-credit
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
entry point. Turning History off preserves saved items; use the separate
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:
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
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
- Advertising, marketing, analytics, product personalization, or tracking
- Advertising, marketing, product personalization, or tracking
- Email address, phone number, physical address, location, contacts, photos,
browsing history, or search history
- 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
- [ ] Replace screenshots with captures from the submitted build
- [ ] 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,
and mapped to the server credit catalog
- [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks
+62 -169
View File
@@ -6,7 +6,7 @@
--surface-strong: #e5eaf0;
--ink: #111416;
--ink-secondary: #5f676e;
--ink-tertiary: #8b949c;
--ink-tertiary: #626c74;
--line: rgba(17, 20, 22, 0.1);
--line-strong: rgba(17, 20, 22, 0.18);
--accent: #2d8a57;
@@ -28,7 +28,7 @@ html[data-theme="dark"] {
--surface-strong: #2b3033;
--ink: #f2f4f3;
--ink-secondary: #aab0ac;
--ink-tertiary: #737a75;
--ink-tertiary: #969e98;
--line: rgba(255, 255, 255, 0.09);
--line-strong: rgba(255, 255, 255, 0.16);
--accent: #6fbd8a;
@@ -97,6 +97,13 @@ h3 {
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 {
position: fixed;
z-index: 100;
@@ -300,27 +307,6 @@ html[data-theme="dark"] #themeToggle::before {
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,
.skill-actions .material-symbols-rounded {
display: none;
@@ -341,16 +327,6 @@ html[data-theme="dark"] #themeToggle::before {
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 {
content: "⌘";
font-size: 1rem;
@@ -467,6 +443,14 @@ html[data-lang="zh"] .hero h1 {
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:focus-visible {
opacity: 0.86;
@@ -718,103 +702,37 @@ html[data-theme="dark"] .message-bubble {
bottom: 24px;
}
.keyboard-demo {
display: flex;
.keyboard-capture {
position: relative;
align-self: center;
min-width: 0;
aspect-ratio: 1.18;
margin: 0;
overflow: hidden;
background: var(--surface-strong);
flex-direction: column;
}
.assistant-tabs {
display: grid;
grid-template-columns: 46px repeat(3, auto) 1fr;
gap: 8px;
align-items: center;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
.keyboard-capture img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
}
.assistant-logo {
display: grid;
width: 36px;
height: 36px;
border-radius: 11px;
background: var(--accent);
color: #fff;
font-size: 0.66rem;
font-weight: 800;
place-items: center;
}
.assistant-tab {
padding: 6px 9px;
.keyboard-capture figcaption {
position: absolute;
top: 16px;
left: 16px;
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
color: var(--ink-tertiary);
font-size: 0.72rem;
background: rgba(17, 20, 22, 0.68);
color: #ffffff;
font-size: 0.68rem;
font-weight: 650;
}
.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);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.gesture-grid {
@@ -1417,29 +1335,28 @@ html[data-theme="dark"] .privacy-section {
.platform-symbol {
display: grid;
width: 110px;
height: 150px;
width: 104px;
height: 104px;
align-self: start;
border: 1px solid var(--line);
border-radius: 28px;
border-radius: 24px;
background: var(--surface-soft);
color: var(--ink-secondary);
place-items: center;
}
.platform-symbol .material-symbols-rounded {
font-size: 42px;
.platform-symbol svg {
width: 48px;
height: 48px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2.6;
}
.mac-symbol {
width: 116px;
height: 96px;
border-radius: 18px;
}
.option-key {
font-size: 2.4rem;
font-weight: 500;
border-radius: 24px;
}
.platform-stories h3 {
@@ -1795,10 +1712,6 @@ html[data-lang="zh"] .download-panel h2 {
border-bottom: 1px solid var(--line);
}
.keyboard-demo {
min-height: 430px;
}
.scene-copy {
max-width: 690px;
}
@@ -1926,7 +1839,7 @@ html[data-lang="zh"] .download-panel h2 {
font-size: clamp(2rem, 9vw, 2.2rem);
}
.hero h1 > span {
html[data-lang="zh"] .hero h1 > span {
white-space: nowrap;
}
@@ -1987,30 +1900,6 @@ html[data-lang="zh"] .download-panel h2 {
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 {
grid-template-columns: 1fr;
}
@@ -2094,14 +1983,18 @@ html[data-lang="zh"] .download-panel h2 {
}
.platform-symbol {
width: 92px;
height: 122px;
border-radius: 23px;
width: 88px;
height: 88px;
border-radius: 21px;
}
.mac-symbol {
height: 76px;
border-radius: 16px;
border-radius: 21px;
}
.platform-symbol svg {
width: 42px;
height: 42px;
}
.setup-steps {
+13 -3
View File
@@ -58,15 +58,20 @@
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"
? "OSGKeyboard — 语音打字与 AI 助手键盘"
: "OSGKeyboard — Voice, typing, and an AI assistant keyboard";
? "OSGKeyboard — 语音输入、中英打字与 AI 助手"
: "OSGKeyboard — Voice input, bilingual typing, and an AI assistant";
if (languageButton) {
languageButton.textContent = lang === "zh" ? "EN" : "中文";
languageButton.setAttribute(
"aria-label",
lang === "zh" ? "Switch to English" : "切换到中文"
lang === "zh" ? "切换到英文" : "Switch to Chinese"
);
}
@@ -131,6 +136,11 @@
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);
}
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
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>OSGKeyboard — 语音打字与 AI 助手键盘</title>
<title>OSGKeyboard — 语音输入、中英打字与 AI 助手</title>
<meta
name="description"
content="OSGKeyboard 是适用于 iPhone、iPad 和 Mac 的语音输入工具。iOS 键盘集成语音听写、中英打字、AI 助手、剪贴板技能可选托管积分默认使用端侧识别。"
data-zh-content="OSGKeyboard 是适用于 iPhone、iPad 和 Mac 的语音输入工具。iOS 键盘集成语音听写、中英打字、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."
content="OSGKeyboard 是一款适用于 iPhone、iPad 和 Mac 的语音与双语输入工具。iOS、iPadOS 版集成听写、中英输入、AI 助手、剪贴板技能可选托管积分服务,并默认使用端侧语音识别。"
data-zh-content="OSGKeyboard 是一款适用于 iPhone、iPad 和 Mac 的语音与双语输入工具。iOS、iPadOS 版集成听写、中英输入、AI 助手、剪贴板技能可选托管积分服务,并默认使用端侧语音识别。"
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="theme-color" content="#eef2f6">
@@ -25,13 +25,13 @@
property="og:title"
content="OSGKeyboard — 开口即文字"
data-zh-content="OSGKeyboard — 开口即文字"
data-en-content="OSGKeyboard — Speak it. It's typed."
data-en-content="OSGKeyboard — Say it. Its typed."
>
<meta
property="og:description"
content="一个键盘完成听写、中英打字、AI 问答剪贴板技能。默认端侧识别,也支持 BYOK 和可选托管积分。"
data-zh-content="一个键盘完成听写、中英打字、AI 问答剪贴板技能。默认端侧识别,也支持 BYOK 和可选托管积分。"
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."
content="用一把键盘完成听写、中英输入、AI 问答剪贴板技能。默认使用端侧识别,也可配置自己的 API 密钥或使用托管积分服务。"
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 your own API key or optional managed credits."
>
<meta property="og:image" content="https://hkgood.github.io/OSGKeyboard/assets/app-icon.png">
<meta name="twitter:card" content="summary">
@@ -56,7 +56,7 @@
"operatingSystem": "iOS 26, iPadOS 26, macOS 15",
"applicationCategory": "UtilitiesApplication",
"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",
"url": "https://hkgood.github.io/OSGKeyboard/",
"offers": {
@@ -71,8 +71,8 @@
"Unified dictation and AI assistant keyboard",
"Clipboard history and AI skills",
"Voice editing of the last verified insertion",
"Optional Sign in with Apple and managed credits on iOS",
"Bring-your-own-key cloud speech and AI",
"Optional Sign in with Apple and managed credits on iOS and iPadOS",
"Cloud speech and AI with your own API key",
"Mac Option-key global dictation with local Qwen3 MLX on Apple Silicon",
"Personal dictionary and optional iCloud sync"
],
@@ -86,7 +86,7 @@
"name": "Does OSGKeyboard require an account?",
"acceptedAnswer": {
"@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?",
"acceptedAnswer": {
"@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?",
"acceptedAnswer": {
"@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>
<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">
<img src="assets/app-icon.png" width="30" height="30" alt="">
<span>OSGKeyboard</span>
@@ -144,13 +144,13 @@
>
<span class="material-symbols-rounded" aria-hidden="true">menu</span>
</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="切换到深色模式">
<span class="material-symbols-rounded" aria-hidden="true">dark_mode</span>
</button>
</div>
</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="#typing" data-zh="输入" data-en="Typing">输入</a>
<a href="#privacy" data-zh="隐私" data-en="Privacy">隐私</a>
@@ -165,19 +165,19 @@
<div class="hero-inner">
<p class="launch-pill">
<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">
OSGKeyboard 2.0 · 为 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 / iPadOS 26
</span>
</p>
<h1>
<span data-zh="声音,直达输入框。" data-en="From voice to text.">声音,直达输入框。</span>
<span class="hero-muted" data-zh="不用离开正在做的事。" data-en="Right where you type.">不用离开正在做的事</span>
<span data-zh="开口,文字直达输入框。" data-en="From voice to text.">开口,文字直达输入框。</span>
<span class="hero-muted" data-zh="无需离开当前 App。" data-en="Without leaving your current app.">无需离开当前 App</span>
</h1>
<p
class="hero-copy"
data-zh="在任意 App 里听写、打字、提问和编辑。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."
>在任意 App 里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。</p>
data-zh="在支持第三方键盘的输入框里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。"
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."
>在支持第三方键盘的输入框里听写、打字、提问和编辑。OSGKeyboard 把语音识别、中英输入与 AI 助手放进同一个键盘。</p>
<div class="hero-actions">
<a
class="store-badge"
@@ -190,9 +190,9 @@
src="assets/badges/ios-zh.svg"
data-zh-src="assets/badges/ios-zh.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"
alt="立即下载 App Store "
alt=" App Store 下载 OSGKeyboard"
width="171"
height="48"
>
@@ -207,10 +207,16 @@
<p
class="hero-note"
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>
</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 class="material-symbols-rounded" aria-hidden="true">south</span>
</a>
@@ -220,18 +226,18 @@
<section class="section product-section" id="assistant">
<div class="section-inner">
<div class="center-heading">
<p class="eyebrow" data-zh="统一助手键盘" data-en="Unified assistant keyboard">统一助手键盘</p>
<h2 data-zh="一句话,从想法直接到输入框。" data-en="One thought. Ready to use where you are.">一句话,从想法直接到输入框</h2>
<p class="eyebrow" data-zh="语音与 AI 助手" data-en="Voice and AI assistant">语音与 AI 助手</p>
<h2 data-zh="说出想法,预览后再插入。" data-en="Speak your thought, preview the result, then insert it.">说出想法,预览后再插入</h2>
<p
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."
>轻点麦克风开始听写,长按直接向 AI 提问。结果先显示、后插入,让每一步都可见、可控。</p>
data-zh="轻点麦克风开始听写,长按直接向 AI 提问。结果会先供你预览,确认后再插入,让每一步都清晰可控。"
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>
</div>
<div class="product-stage">
<div class="stage-bar">
<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>
</div>
<div class="stage-content">
@@ -245,30 +251,27 @@
<div class="insert-preview">
<span class="preview-kicker" data-zh="准备插入" data-en="Ready to insert">准备插入</span>
<p
data-zh="内容已经整理得更清楚,同时保留了原本的表达方式。"
data-zh="已理清内容,同时保留原有语气。"
data-en="The message is clearer while preserving your original tone."
>内容已经整理得更清楚,同时保留了原本的表达方式</p>
>已理清内容,同时保留原有语气</p>
<span class="text-cursor" aria-hidden="true"></span>
</div>
</div>
<div class="keyboard-demo" aria-hidden="true">
<div class="assistant-tabs">
<span class="assistant-logo">OSG</span>
<span class="assistant-tab active" data-zh="助手" data-en="Assistant">助手</span>
<span class="assistant-tab">中文</span>
<span class="assistant-tab">EN</span>
<span class="material-symbols-rounded">more_horiz</span>
</div>
<div class="keyboard-keys">
<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>
<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>
</div>
<div class="assistant-controls">
<span class="round-control"><span class="material-symbols-rounded">undo</span></span>
<span class="mic-control"><span class="material-symbols-rounded">mic</span></span>
<span class="round-control"><span class="material-symbols-rounded">arrow_upward</span></span>
</div>
</div>
<figure
class="keyboard-capture"
aria-label="OSGKeyboard 自定义键盘实机画面"
data-zh-label="OSGKeyboard 自定义键盘实机画面"
data-en-label="Actual OSGKeyboard custom keyboard"
>
<img
src="assets/screenshots/zh/light/iphone-keyboard_recording.png"
data-shot="iphone-keyboard_recording.png"
data-zh-alt="OSGKeyboard 在 iPhone 输入框中进行语音输入"
data-en-alt="OSGKeyboard dictating into an iPhone text field"
alt="OSGKeyboard 在 iPhone 输入框中进行语音输入"
>
<figcaption data-zh="iPhone 实机键盘画面" data-en="Actual OSGKeyboard on iPhone">iPhone 实机键盘画面</figcaption>
</figure>
</div>
</div>
@@ -276,20 +279,20 @@
<article>
<span class="gesture-number">01</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>
</article>
<article>
<span class="gesture-number">02</span>
<span class="material-symbols-rounded" aria-hidden="true">auto_fix_high</span>
<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>
<span class="gesture-number">03</span>
<span class="material-symbols-rounded" aria-hidden="true">text_fields</span>
<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>
</div>
</div>
@@ -302,29 +305,29 @@
<p class="eyebrow" data-zh="语音输入" data-en="Voice input">语音输入</p>
<h2 data-zh="开口即文字,不打断思路。" data-en="Speak naturally. Keep your train of thought.">开口即文字,不打断思路。</h2>
<p
data-zh="本地识别可以直接开始。热词、个人词典与上下文补偿,让名字、术语和常用表达准确。"
data-en="Start immediately with local recognition. Hotwords, a personal dictionary, and context compensation improve names, terminology, and phrases you use often."
>本地识别可以直接开始。热词、个人词典与上下文补偿,让名字、术语和常用表达准确。</p>
data-zh="完成系统授权后即可使用端侧识别。个人词典与上下文词语提示有助于提高名字、术语和常用表达的识别准确。"
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>
<ul class="quiet-list">
<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="可用语音重写上一次已验证输入" data-en="Rewrite the last verified insertion by voice">可用语音重写上一次已验证输入</li>
<li data-zh="云端识别 AI 润色均为可选" data-en="Cloud recognition and AI polishing are always optional">云端识别 AI 润色均为可选</li>
<li data-zh="可用语音修改最近一次由 OSGKeyboard 插入的文字" data-en="Revise the most recent text inserted by OSGKeyboard using your voice">可用语音修改最近一次由 OSGKeyboard 插入的文字</li>
</ul>
</div>
<div class="voice-stage">
<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>
</div>
<div class="transcript-card">
<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>
<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>
</div>
<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>
</div>
<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>
</div>
</div>
@@ -336,12 +339,12 @@
<div class="section-inner">
<div class="center-heading compact">
<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 class="typing-stories">
<article class="typing-story">
<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="candidate-row">
<span class="selected">今天</span><span>今天的</span><span>近天</span><span>金田</span>
@@ -352,11 +355,11 @@
</div>
<div class="story-copy">
<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
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."
>全拼、自然码、小鹤、微软与搜狗双拼候选分页、数字选词、滑动选词与中英混输一并具备</p>
data-zh="支持全拼、自然码、小鹤、微软与搜狗双拼,同时提供候选分页、数字选词、滑动选词与中英混输。"
data-en="Full pinyin plus Ziranma, Xiaohe, Microsoft, and Sogou double pinyin—with candidate paging, number selection, swiping, and mixed Chinese-English input."
>支持全拼、自然码、小鹤、微软与搜狗双拼,同时提供候选分页、数字选词、滑动选词与中英混输。</p>
</div>
</article>
<article class="typing-story reverse">
@@ -368,11 +371,11 @@
</div>
<div class="story-copy">
<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
data-zh="前缀补全、自动纠错、下一词预测、候选撤销与句首大小写,让双语切换保持同样流畅。"
data-zh="支持前缀补全、自动纠错、下一词预测、撤销自动纠错和句首自动大写,中英文切换也能保持流畅。"
data-en="Prefix completion, autocorrect, next-word prediction, candidate undo, and sentence capitalization keep bilingual typing fluid."
>前缀补全、自动纠错、下一词预测、候选撤销与句首大小写,让双语切换保持同样流畅。</p>
>支持前缀补全、自动纠错、下一词预测、撤销自动纠错和句首自动大写,中英文切换也能保持流畅。</p>
</div>
</article>
</div>
@@ -405,21 +408,21 @@
</div>
<div class="scene-copy">
<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
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."
>从剪贴板历史继续工作,把已有文字变成回复、总结、翻译、待办、日程或导航。所有读取都由你主动开启。</p>
data-zh="从剪贴板历史中选择内容,生成回复、摘要或译文,也可提取待办、日程和地址并发起导航。剪贴板访问默认关闭,仅在你主动开启后读取。"
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>
</div>
</div>
<div class="styles-strip">
<div>
<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 class="style-options">
<span data-zh="轻度清理" data-en="Light cleanup">轻度清理</span>
<span class="selected" data-zh="清结构" data-en="Clear structure">结构</span>
<span data-zh="轻度润色" data-en="Light polish">轻度润色</span>
<span class="selected" data-zh="清结构" data-en="Clarify structure">清结构</span>
<span data-zh="正式表达" data-en="Formal">正式表达</span>
<span data-zh="日常聊天" data-en="Casual">日常聊天</span>
</div>
@@ -432,46 +435,46 @@
<div class="section-inner">
<div class="center-heading inverse">
<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
data-zh="先使用本地能力。需要更强模型时,再选择自己的 API Key 或可选托管积分。路径透明,决定权始终在你。"
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."
>先使用本地能力。需要更强模型时,再选择自己的 API Key 或可选托管积分。路径透明,决定权始终在你。</p>
data-zh="默认优先使用端侧能力。需要云端识别或 AI 功能时,可配置自己的 API 密钥,或选择托管积分服务。每种处理方式都会清晰标示,决定权始终在你。"
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."
>默认优先使用端侧能力。需要云端识别或 AI 功能时,可配置自己的 API 密钥,或选择托管积分服务。每种处理方式都会清晰标示,决定权始终在你。</p>
</div>
<div class="service-paths">
<article>
<span class="path-number">01</span>
<span class="material-symbols-rounded" aria-hidden="true">memory</span>
<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.">
默认 Apple 本地识别;Mac Apple Silicon 还可使用 Qwen3 MLX
<p data-zh="iOS、iPadOS 默认使用 Apple 设备端语音识别;搭载 Apple 芯片的 Mac 默认使用 Qwen3 MLXApple 语音识别作为备用方案。" 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.">
iOS、iPadOS 默认使用 Apple 设备端语音识别;搭载 Apple 芯片的 Mac 默认使用 Qwen3 MLX,Apple 语音识别作为备用方案
</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>
<span class="path-number">02</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>
<p data-zh="连接你选择的语音或 AI 服务商凭证保存在 Keychain。" data-en="Connect the speech or AI provider you choose. Credentials stay in Keychain.">
连接你选择的语音或 AI 服务商凭证保存在 Keychain
<h3 data-zh="使用自己的 API 密钥" data-en="Use your own API key">使用自己的 API 密钥</h3>
<p data-zh="配置你选择的语音识别或 AI 服务商凭证存储在系统钥匙串中。" data-en="Configure the speech-recognition or AI provider you choose. Credentials are securely stored in the system Keychain.">
配置你选择的语音识别或 AI 服务商凭证存储在系统钥匙串中
</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>
<span class="path-number">03</span>
<span class="material-symbols-rounded" aria-hidden="true">toll</span>
<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.">
iOS / iPadOS 可选 Apple 登录积分服务,无需自行管理 Key
<p data-zh="iOSiPadOS 上,可通过 Apple 登录使用托管积分服务,无需自行管理 API 密钥。" data-en="On iOS and iPadOS, you can optionally use Sign in with Apple and managed credits without managing an API key.">
iOSiPadOS 上,可通过 Apple 登录使用托管积分服务,无需自行管理 API 密钥
</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>
</div>
<div class="privacy-proof">
<div>
<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>
</div>
<a class="inverse-link" href="privacy/">
@@ -487,16 +490,19 @@
<div class="section-inner">
<div class="center-heading compact">
<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 class="platform-stories">
<article>
<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>
<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.">
听写、AI 助手、中文与英文输入、剪贴板技能、个人词典和可选 iCloud 同步。
</p>
@@ -504,20 +510,22 @@
</article>
<article>
<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>
<span class="platform-label">macOS 15+</span>
<h3 data-zh="按住 Option,在任何位置开始听写" data-en="Hold Option to dictate anywhere">按住 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.">
全局热键底部实时浮层、Apple Speech 回退,以及 Apple Silicon 上的 Qwen3 MLX 本地模型
<h3 data-zh="按住 Option 键,在支持文本输入的位置开始听写" data-en="Hold Option to start dictation where text input is supported">按住 Option 键,在支持文本输入的位置开始听写</h3>
<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.">
支持 Option 全局热键和屏幕底部实时听写浮窗;搭载 Apple 芯片的 Mac 使用 Qwen3 MLX 本地模型,并以 Apple 语音识别作为备用方案
</p>
</div>
</article>
</div>
<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.">
公开 Mac DMG 仍为历史版 1.1;最新 Mac 2.0 能力可从源码构建。
<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.0 的最新功能,请从源码构建。
</span>
</p>
</div>
@@ -529,29 +537,29 @@
<div class="split-heading">
<div>
<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>
<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>
</div>
<ol class="setup-steps">
<li>
<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.">
iOS 添加键盘并开启完全访问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、iPadOS 添加键盘并开启完全访问”;在 Mac 授予麦克风辅助功能权限。
</p>
</li>
<li>
<h3 data-zh="选择服务路径" data-en="Choose a service path">选择服务路径</h3>
<p data-zh="直接使用本地识别,或按需添加自己的 Key、登录托管积分。" data-en="Use local recognition, add your own key, or sign in for managed credits as needed.">
直接使用本地识别,或按需添加自己的 Key、登录托管积分。
<h3 data-zh="选择处理方式" data-en="Choose how input is processed">选择处理方式</h3>
<p data-zh="使用端侧识别,或按需添加自己的 API 密钥、登录后使用托管积分。" data-en="Use on-device recognition, add your own API key, or sign in to use managed credits.">
使用端侧识别,或按需添加自己的 API 密钥、登录后使用托管积分。
</p>
</li>
<li>
<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.">
轻点听写、长按提问,或切换到中文 / English 键盘。
<p data-zh="轻点开始听写、长按进行提问,或切换到中文或英文键盘。" data-en="Tap to dictate, hold to ask, or switch between Chinese and English typing.">
轻点开始听写、长按进行提问,或切换到中文或英文键盘。
</p>
</li>
</ol>
@@ -559,10 +567,10 @@
<div class="stable-strip">
<span data-zh="稳定性同样是功能" data-en="Reliability is a feature">稳定性同样是功能</span>
<ul>
<li data-zh="扩展冷启动保护" data-en="Extension cold-start protection">扩展冷启动保护</li>
<li data-zh="内存压力降级" data-en="Memory-pressure fallback">内存压力降级</li>
<li data-zh="上下文安全插入" data-en="Context-safe insertion">上下文安全插入</li>
<li data-zh="中断后恢复" data-en="Recovery after interruption">中断后恢复</li>
<li data-zh="键盘启动保护" data-en="Reliable keyboard startup">键盘启动保护</li>
<li data-zh="内存不足时自动降级" data-en="Automatic fallback when memory is low">内存不足时自动降级</li>
<li data-zh="确认输入位置后再插入" data-en="Insert only after confirming the input position">确认输入位置后再插入</li>
<li data-zh="中断后恢复" data-en="Recover after interruptions">中断后恢复</li>
</ul>
</div>
</div>
@@ -573,37 +581,37 @@
<div class="section-inner faq-layout">
<div>
<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 class="faq-list">
<details class="faq-item">
<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.">
不需要。本地听写和自备服务商无需 OSG 账号。Apple 登录仅用于可选托管积分、购买、邀请资料管理。
<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 登录仅用于托管积分、积分购买、邀请奖励和账号资料管理。
</p>
</details>
<details class="faq-item">
<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>
</details>
<details class="faq-item">
<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.">
完全访问让键盘扩展与主 App 通过 App Group 协作、读取共享 Keychain 中的用户凭证,并在你主动启后访问剪贴板。它不代表我们会收集普通击键
<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 共享设置、读取已保存的服务配置,并在你主动启后访问剪贴板。此权限不代表 OSGKeyboard 会收集日常键入内容
</p>
</details>
<details class="faq-item">
<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>
</details>
<details class="faq-item">
<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.">
当前公开、已签名并公证的 DMG 是历史版 1.1。仓库中的 Mac target 随整体源码演进到 2.0;需要最新源码能力时可在 macOS Xcode 26 自行构建。
<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.">
当前可下载、已签名并公证的 Mac 安装包为 1.1。仓库中的 Mac 版源码已更新至 2.0.0;如需最新功能,请在 macOS 上使用 Xcode 26 或更高版本自行构建。
</p>
</details>
</div>
@@ -615,10 +623,10 @@
<div class="section-inner">
<div class="download-panel">
<div>
<p class="eyebrow" data-zh="开口即文字" data-en="Speak it. It's typed.">开口即文字</p>
<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 class="eyebrow" data-zh="开口即文字" data-en="Say it. Its typed.">开口即文字</p>
<h2 data-zh="下一段文字,开口就能输入。" data-en="Your next sentence starts with your voice.">下一段文字,开口就能输入</h2>
<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>
</div>
<div class="download-actions">
@@ -633,9 +641,9 @@
src="assets/badges/ios-zh.svg"
data-zh-src="assets/badges/ios-zh.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"
alt="立即下载 App Store "
alt=" App Store 下载 OSGKeyboard"
width="171"
height="48"
>
@@ -645,9 +653,9 @@
src="assets/badges/macos-zh.svg"
data-zh-src="assets/badges/macos-zh.svg"
data-en-src="assets/badges/macos-en.svg"
data-zh-alt="下载 macOS 历史版本 1.1"
data-en-alt="Download historical macOS version 1.1"
alt="下载 macOS 历史版本 1.1"
data-zh-alt="下载 macOS 1.1 旧版本"
data-en-alt="Download macOS 1.1 (older version)"
alt="下载 macOS 1.1 旧版本"
width="171"
height="48"
>
@@ -669,7 +677,7 @@
<footer class="site-footer">
<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 · 源码可见,非开源软件
</span>
<div class="footer-links">
+46 -28
View File
@@ -182,41 +182,59 @@
<div class="lang-block" data-lang="zh">
<section class="section">
<h2>可选 OSG 账号</h2>
<ul>
<li>新增 Apple 登录、账号资料管理和 App 内账号注销</li>
<li>新增托管积分、App Store 积分包与邀请功能</li>
<li>登录后可使用托管云端语音与 AI 服务;本地听写和用户自备 API Key 无需账号,仍可独立使用</li>
</ul>
</section>
<h2>全新积分体验</h2>
<section class="section">
<h2>助手体验升级</h2>
<ul>
<li>语音与 AI 合并为统一的助手入口,并根据当前输入框显示对应操作</li>
<li>AI 回答仅在输入框与光标上下文仍匹配时自动插入,避免写入错误位置</li>
<li>修正退出登录与注销账号确认弹窗的锚点位置</li>
</ul>
<div class="group">
<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>
<li>每成功邀请一位好友,你和好友各获得<strong>1000 积分</strong></li>
<li>可在设置的账号区域分享邀请链接</li>
</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>
</div>
<div class="lang-block" data-lang="en">
<section class="section">
<h2>Optional OSG Account</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>
<h2>A New Credits Experience</h2>
<section class="section">
<h2>Assistant Improvements</h2>
<ul>
<li>Combines Voice and AI into one Assistant entry with actions adapted to the focused field</li>
<li>Inserts AI answers automatically only while the original field and cursor context still match</li>
<li>Fixes the anchors for sign-out and account-deletion confirmation dialogs</li>
</ul>
<div class="group">
<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>
<li>For every successful referral, you and your friend each receive <strong>1,000 credits</strong></li>
<li>Share your invitation link from the Account section in Settings</li>
</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>
</div>
</article>
+7 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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">
<link rel="canonical" href="https://hkgood.github.io/OSGKeyboard/privacy/">
<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>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>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>
<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>
<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> 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> sell personal data.</li>
</ul>
@@ -79,6 +80,7 @@
<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><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>
<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>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。</li>
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,并携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识。绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。</li>
</ul>
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
<h2>我们不收集的内容</h2>
<ul>
<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>
@@ -141,6 +144,7 @@
<h2>数据保留</h2>
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计、词库和语音历史可能经私有 iCloud 账户同步。语音历史上限 300 条,可在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。中英文输入学习数据仅留本机,可在输入设置中单独清除且不影响个性词库。下载的 Mac 语音模型会保留到你在应用中移除模型或删除本地文件。</p>
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期,服务端只接受 35 天以内的事件。注销账号或关闭产品分析会删除本地队列。分析标识不会用于跨 App 跟踪或广告。</p>
<h2>儿童隐私</h2>
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
+5 -1
View File
@@ -49,7 +49,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "2.0.0"
CURRENT_PROJECT_VERSION: "83"
CURRENT_PROJECT_VERSION: "84"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 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."
UIBackgroundModes:
- audio
BGTaskSchedulerPermittedIdentifiers:
- com.osgkeyboard.ios.analytics-sync
NSAppTransportSecurity:
NSAllowsArbitraryLoads: false
ITSAppUsesNonExemptEncryption: false
@@ -404,6 +406,7 @@ targets:
dependencies:
- package: Librime
product: RimeStatic
- sdk: libsqlite3.tbd
# Host-only support framework (ASR / CLM / Cloud / Charts / StoreKit).
# Keyboard extension does NOT link this — keeps Speech/AVFoundation/Charts
@@ -665,6 +668,7 @@ targets:
- sdk: AVFoundation.framework
- sdk: Charts.framework
- sdk: StoreKit.framework
- sdk: libsqlite3.tbd
schemes:
OSGKeyboard: