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
@@ -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)