Expand onboarding and adaptive keyboard intelligence

Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
@@ -89,7 +89,6 @@ public enum AnalyticsModelError: Error, Sendable {
}
public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let installationId: UUID
public let clientEventId: UUID
public let eventType: AnalyticsEventType
public let occurredAt: Date
@@ -103,7 +102,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let durationBucket: AnalyticsDurationBucket?
public init(
installationId: UUID,
clientEventId: UUID,
eventType: AnalyticsEventType,
occurredAt: Date,
@@ -121,7 +119,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
throw AnalyticsModelError.invalidVersion
}
self.installationId = installationId
self.clientEventId = clientEventId
self.eventType = eventType
self.occurredAt = occurredAt
@@ -140,7 +137,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case clientEventId
case eventType
case occurredAt
@@ -160,7 +156,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
let installationId = try container.decode(UUID.self, forKey: .installationId)
let clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
@@ -172,7 +167,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
)
}
try self.init(
installationId: installationId,
clientEventId: clientEventId,
eventType: eventType,
occurredAt: occurredAt,
@@ -204,7 +198,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
throw AnalyticsModelError.invalidDimensions(eventType)
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(installationId, forKey: .installationId)
try container.encode(clientEventId, forKey: .clientEventId)
try container.encode(eventType, forKey: .eventType)
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
@@ -237,13 +230,16 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
}
public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let events: [AnalyticsEvent]
public init(events: [AnalyticsEvent]) {
public init(installationId: UUID, events: [AnalyticsEvent]) {
self.installationId = installationId
self.events = events
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case events
}
@@ -253,6 +249,7 @@ public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
installationId = try container.decode(UUID.self, forKey: .installationId)
events = try container.decode([AnalyticsEvent].self, forKey: .events)
}
}
@@ -335,6 +332,7 @@ private extension AnalyticsEventType {
switch self {
case .firstOpen, .inviteOpened:
return [
.none,
DimensionSet(
acquisitionChannel: true,
feature: false,
@@ -72,6 +72,7 @@ struct AnalyticsLeasedEvent: Sendable {
struct AnalyticsLeasedBatch: Sendable {
let leaseID: String
let installationID: UUID
let events: [AnalyticsLeasedEvent]
let body: Data
}
@@ -141,7 +142,6 @@ public actor AnalyticsRepository {
return
}
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: .firstOpen,
occurredAt: clock.now(),
@@ -150,7 +150,11 @@ public actor AnalyticsRepository {
osVersion: context.environment.osVersion,
acquisitionChannel: firstOpenAcquisitionChannel
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try setMetadata(
"1",
for: MetadataKey.firstOpenRecorded,
@@ -172,6 +176,21 @@ public actor AnalyticsRepository {
}
}
/// Returns the existing App Group installation UUID, creating it only while
/// analytics is enabled. Aggregate keyboard usage reuses this identity.
public func installationIdentifierIfEnabled() -> UUID? {
guard let database = openDatabaseIfNeeded() else { return nil }
do {
return try database.immediateTransaction {
_ = try ensureEnabled(database)
guard try isEnabled(database) else { return nil }
return try ensureInstallation(database)
}
} catch {
return nil
}
}
/// Read-only diagnostics for tests and support tooling. Event payload content
/// is deliberately excluded from this public snapshot.
public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot {
@@ -355,7 +374,6 @@ public actor AnalyticsRepository {
guard try isEnabled(database) else { return }
let installationID = try ensureInstallation(database)
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: eventType,
occurredAt: clock.now(),
@@ -368,7 +386,11 @@ public actor AnalyticsRepository {
failureCategory: dimensions.failureCategory,
durationBucket: dimensions.durationBucket
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try enforceStoragePolicy(database)
}
}
@@ -402,7 +424,6 @@ public actor AnalyticsRepository {
let installationID = try ensureInstallation(database)
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: .sessionStarted,
occurredAt: now,
@@ -410,7 +431,11 @@ public actor AnalyticsRepository {
appVersion: context.environment.appVersion,
osVersion: context.environment.osVersion
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try setMetadata(
String(now.timeIntervalSince1970),
for: key,
@@ -441,19 +466,42 @@ public actor AnalyticsRepository {
try releaseExpiredEventLeases(now: now, database: database)
try deleteExpiredEvents(now: now, database: database)
guard let installationText = try database.query(
"""
SELECT installation_id
FROM pending_events
WHERE next_attempt_at <= ? AND lease_id IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT 1
""",
bindings: [.double(now)]
).first?.text(at: 0),
let installationID = UUID(uuidString: installationText) else {
try clearUploadLease(database)
return nil
}
let candidates = try database.query(
"""
SELECT id, payload, attempt_count
FROM pending_events
WHERE next_attempt_at <= ? AND lease_id IS NULL
WHERE installation_id = ?
AND next_attempt_at <= ?
AND lease_id IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT ?
""",
bindings: [.double(now), .int64(Int64(upload.maximumBatchCount))]
bindings: [
.text(installationText),
.double(now),
.int64(Int64(upload.maximumBatchCount))
]
)
var selected: [AnalyticsLeasedEvent] = []
var bodySize = Self.emptyRequestBody.count
var bodySize = Self.emptyRequestBody(
installationID: installationID
).count
for row in candidates {
guard let rowID = row.int64(at: 0),
let payload = row.data(at: 1),
@@ -504,8 +552,12 @@ public actor AnalyticsRepository {
}
return AnalyticsLeasedBatch(
leaseID: leaseID,
installationID: installationID,
events: selected,
body: Self.requestBody(for: selected)
body: Self.requestBody(
for: selected,
installationID: installationID
)
)
}
} catch {
@@ -696,18 +748,20 @@ public actor AnalyticsRepository {
private func insert(
_ event: AnalyticsEvent,
installationID: UUID,
database: SQLiteDatabase
) throws {
let payload = try AnalyticsCanonicalJSON.encode(event)
try database.execute(
"""
INSERT OR IGNORE INTO pending_events (
client_event_id, event_type, occurred_at, surface, payload,
payload_size, priority, lease_id, lease_expires_at,
installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, priority, lease_id, lease_expires_at,
attempt_count, next_attempt_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
""",
bindings: [
.text(installationID.uuidString.lowercased()),
.text(event.clientEventId.uuidString.lowercased()),
.text(event.eventType.rawValue),
.double(event.occurredAt.timeIntervalSince1970),
@@ -887,11 +941,12 @@ public actor AnalyticsRepository {
try database.execute(
"""
INSERT INTO quarantined_events (
client_event_id, event_type, occurred_at, surface, payload,
payload_size, attempt_count, reason, quarantined_at
installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, attempt_count, reason,
quarantined_at
)
SELECT client_event_id, event_type, occurred_at, surface, payload,
payload_size, attempt_count, ?, ?
SELECT installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, attempt_count, ?, ?
FROM pending_events
WHERE id = ?\(leaseClause)
""",
@@ -907,10 +962,18 @@ public actor AnalyticsRepository {
try enforceStoragePolicy(database)
}
private static let emptyRequestBody = Data(#"{"events":[]}"#.utf8)
private static func emptyRequestBody(installationID: UUID) -> Data {
requestBody(for: [], installationID: installationID)
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
private static func requestBody(
for events: [AnalyticsLeasedEvent],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
.utf8
)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
@@ -967,7 +1030,7 @@ public actor AnalyticsRepository {
private func migrate(_ database: SQLiteDatabase) throws {
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
guard version <= 1 else {
guard version <= 2 else {
throw SQLiteStoreError(
code: SQLITE_MISMATCH,
category: .schema,
@@ -984,58 +1047,90 @@ public actor AnalyticsRepository {
) WITHOUT ROWID
"""
)
try createCurrentQueueSchema(database)
try database.execute("PRAGMA user_version = 2")
}
} else if version == 1 {
try database.immediateTransaction {
// Another process may have completed the migration while this
// connection waited for BEGIN IMMEDIATE.
guard try database.scalarInt64("PRAGMA user_version") == 1 else {
return
}
// v1 payloads contain installationId inside each event and are
// incompatible with the server's strict v2 envelope. Drop them
// without decoding so neither old content nor identifiers can
// escape through logs or a partially migrated retry.
try database.execute("DROP TABLE IF EXISTS pending_events")
try database.execute("DROP TABLE IF EXISTS quarantined_events")
try createCurrentQueueSchema(database)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL CHECK(payload_size >= 0),
priority INTEGER NOT NULL,
lease_id TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
"""
"DELETE FROM metadata WHERE key IN (?, ?, ?)",
bindings: [
.text(MetadataKey.firstOpenRecorded),
.text(MetadataKey.uploadLeaseOwner),
.text(MetadataKey.uploadLeaseExpiresAt)
]
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_ready
ON pending_events(next_attempt_at, lease_id, priority, created_at)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_expiry
ON pending_events(occurred_at)
"""
)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS quarantined_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL,
attempt_count INTEGER NOT NULL,
reason TEXT NOT NULL,
quarantined_at REAL NOT NULL
)
"""
)
try database.execute("PRAGMA user_version = 1")
try database.execute("PRAGMA user_version = 2")
}
}
}
private func createCurrentQueueSchema(_ database: SQLiteDatabase) throws {
try database.execute(
"""
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL CHECK(payload_size >= 0),
priority INTEGER NOT NULL,
lease_id TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_ready
ON pending_events(
installation_id, next_attempt_at, lease_id, priority, created_at
)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_expiry
ON pending_events(occurred_at)
"""
)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS quarantined_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL,
attempt_count INTEGER NOT NULL,
reason TEXT NOT NULL,
quarantined_at REAL NOT NULL
)
"""
)
}
private func applyFileProtection(to databaseURL: URL) {
#if os(iOS)
for url in [
@@ -1161,7 +1256,7 @@ private struct SQLiteStoreError: Error, Sendable {
let operation: String
}
private enum SQLiteBinding {
enum SQLiteBinding {
case null
case int64(Int64)
case double(Double)
@@ -1169,7 +1264,7 @@ private enum SQLiteBinding {
case blob(Data)
}
private struct SQLiteRow {
struct SQLiteRow {
private let values: [SQLiteValue]
init(statement: OpaquePointer) {
@@ -1243,7 +1338,7 @@ private enum SQLiteValue {
case blob(Data)
}
private final class SQLiteDatabase {
final class SQLiteDatabase {
private static let transient = unsafeBitCast(
-1,
to: sqlite3_destructor_type.self
@@ -77,7 +77,7 @@ public actor AnalyticsUploadCoordinator {
/// Performs a bounded drain. The default keeps extension execution time and
/// memory predictable while allowing a host app to request more batches.
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
guard !uploadInProgress else { return }
guard !uploadInProgress, !Task.isCancelled else { return }
uploadInProgress = true
defer { uploadInProgress = false }
@@ -95,18 +95,35 @@ public actor AnalyticsUploadCoordinator {
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
let batchLimit = max(1, maximumBatches)
for _ in 0..<batchLimit {
guard !Task.isCancelled else { return }
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
guard !Task.isCancelled else {
await releaseForCancellation(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
if Task.isCancelled {
await releaseForCancellation(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
await scheduleRetry(
events: batch.events,
leaseID: batch.leaseID,
@@ -120,21 +137,28 @@ public actor AnalyticsUploadCoordinator {
await process(
events: batch.events,
installationID: batch.installationID,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
guard !Task.isCancelled else { return }
}
}
private func process(
events: [AnalyticsLeasedEvent],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !events.isEmpty else { return }
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
guard await renewLease(
events: events,
leaseID: leaseID,
@@ -142,11 +166,23 @@ public actor AnalyticsUploadCoordinator {
) else {
return
}
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(events: events, token: authorization.token)
response = try await send(
events: events,
installationID: installationID,
token: authorization.token
)
} catch {
if Task.isCancelled {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
await scheduleRetry(
events: events,
leaseID: leaseID,
@@ -172,15 +208,24 @@ public actor AnalyticsUploadCoordinator {
) else {
return
}
let refreshed = try await send(events: events, token: authorization.token)
let refreshed = try await send(
events: events,
installationID: installationID,
token: authorization.token
)
await processResponse(
refreshed,
events: events,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
if Task.isCancelled {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
await scheduleRetry(
events: events,
leaseID: leaseID,
@@ -195,6 +240,7 @@ public actor AnalyticsUploadCoordinator {
await processResponse(
response,
events: events,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
@@ -204,10 +250,15 @@ public actor AnalyticsUploadCoordinator {
private func processResponse(
_ response: AnalyticsHTTPResponse,
events: [AnalyticsLeasedEvent],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
switch response.statusCode {
case 200:
let decoded: AnalyticsUploadResponse
@@ -270,12 +321,14 @@ public actor AnalyticsUploadCoordinator {
let midpoint = events.count / 2
await process(
events: Array(events[..<midpoint]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
events: Array(events[midpoint...]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
@@ -340,8 +393,18 @@ public actor AnalyticsUploadCoordinator {
}
}
private func releaseForCancellation(
events: [AnalyticsLeasedEvent],
leaseID: String,
ownerID: String
) async {
await repository.releaseEvents(events, leaseID: leaseID)
await repository.releaseGlobalLease(ownerID: ownerID)
}
private func send(
events: [AnalyticsLeasedEvent],
installationID: UUID,
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
@@ -355,7 +418,10 @@ public actor AnalyticsUploadCoordinator {
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(for: events)
body: Self.requestBody(
for: events,
installationID: installationID
)
)
)
}
@@ -445,8 +511,14 @@ public actor AnalyticsUploadCoordinator {
events.map(\.attemptCount).max() ?? 0
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
private static func requestBody(
for events: [AnalyticsLeasedEvent],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
.utf8
)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
@@ -0,0 +1,129 @@
// KeyboardUsageClient.swift
// OSGKeyboard · Shared
//
// Fire-and-forget numeric recording facade. Calls are serialized per process so
// a UTC rollover cannot finalize a day ahead of an earlier queued insertion.
import Foundation
public protocol KeyboardUsageRecording: Sendable {
func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
)
}
public struct NoopKeyboardUsageRecorder: KeyboardUsageRecording {
public init() {}
public func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
) {}
}
public final class LiveKeyboardUsageRecorder:
KeyboardUsageRecording,
@unchecked Sendable {
private let analyticsRepository: AnalyticsRepository
private let repository: KeyboardUsageRepository
private let environment: AnalyticsEnvironment
private let clock: any AnalyticsWallClock
private let trigger: any AnalyticsUploadTriggering
private let lock = NSLock()
private var tailTask: Task<Void, Never>?
public init(
analyticsRepository: AnalyticsRepository,
repository: KeyboardUsageRepository,
environment: AnalyticsEnvironment,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger()
) {
self.analyticsRepository = analyticsRepository
self.repository = repository
self.environment = environment
self.clock = clock
self.trigger = trigger
}
public func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
) {
guard !counts.isEmpty else { return }
let occurredAt = clock.now()
let analyticsRepository = analyticsRepository
let repository = repository
let environment = environment
let trigger = trigger
lock.withLock {
let previous = tailTask
tailTask = Task {
await previous?.value
guard let installationID =
await analyticsRepository.installationIdentifierIfEnabled() else {
return
}
await repository.record(
counts: counts,
sessionID: sessionID,
installationID: installationID,
occurredAt: occurredAt,
environment: environment
)
await trigger.requestUpload()
}
}
}
func waitForPendingRecords() async {
let task = lock.withLock { tailTask }
await task?.value
}
}
public struct KeyboardUsageRuntime: Sendable {
public let repository: KeyboardUsageRepository
public let recorder: any KeyboardUsageRecording
public let uploadCoordinator: KeyboardUsageUploadCoordinator
public init(
environment: AnalyticsEnvironment,
analyticsRepository: AnalyticsRepository,
repositoryConfiguration: KeyboardUsageRepositoryConfiguration = .appGroupDefault(),
uploadConfiguration: KeyboardUsageUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
let repository = KeyboardUsageRepository(
configuration: repositoryConfiguration,
clock: wallClock,
uuidGenerator: uuidGenerator
)
self.repository = repository
recorder = LiveKeyboardUsageRecorder(
analyticsRepository: analyticsRepository,
repository: repository,
environment: environment,
clock: wallClock,
trigger: trigger
)
uploadCoordinator = KeyboardUsageUploadCoordinator(
repository: repository,
configuration: uploadConfiguration,
network: network,
bearerProvider: bearerProvider,
clock: wallClock,
uuidGenerator: uuidGenerator,
random: random,
logger: logger
)
}
}
@@ -0,0 +1,424 @@
// KeyboardUsageModels.swift
// OSGKeyboard · Shared
//
// Fixed-schema keyboard usage summaries. Raw text never crosses this model
// boundary: callers provide only grapheme counts and random session IDs.
import Foundation
public enum KeyboardTextInsertionSource: String, CaseIterable, Sendable {
case manualKeyboard = "MANUAL_KEYBOARD"
case voiceTranscription = "VOICE_TRANSCRIPTION"
case aiGenerated = "AI_GENERATED"
case pasteboard = "PASTEBOARD"
case editGenerated = "EDIT_GENERATED"
case redo = "REDO"
case assistantAction = "ASSISTANT_ACTION"
case debugDemo = "DEBUG_DEMO"
public var contributesToKeyboardUsage: Bool {
self == .manualKeyboard
}
}
public struct KeyboardUsageCharacterCounts: Equatable, Sendable {
public static let maximumPerCategory = 1_000_000
public let chinese: Int
public let english: Int
public let other: Int
public init(chinese: Int = 0, english: Int = 0, other: Int = 0) {
self.chinese = Self.clamped(chinese)
self.english = Self.clamped(english)
self.other = Self.clamped(other)
}
public var total: Int {
chinese + english + other
}
public var isEmpty: Bool {
total == 0
}
private static func clamped(_ value: Int) -> Int {
min(maximumPerCategory, max(0, value))
}
}
public enum KeyboardUsageCharacterClassifier {
public static func classify(_ text: String) -> KeyboardUsageCharacterCounts {
var chinese = 0
var english = 0
var other = 0
for character in text {
switch classification(of: character) {
case .chinese:
chinese = saturatingIncrement(chinese)
case .english:
english = saturatingIncrement(english)
case .other:
other = saturatingIncrement(other)
}
}
return KeyboardUsageCharacterCounts(
chinese: chinese,
english: english,
other: other
)
}
private enum Classification {
case chinese
case english
case other
}
private static func classification(of character: Character) -> Classification {
// ICU Script properties cover CJK extensions and future Unicode updates;
// checking the whole grapheme keeps combining sequences at one count.
if character.unicodeScalars.contains(where: {
matchesScript($0, regularExpression: hanScriptExpression)
}) {
return .chinese
}
if character.unicodeScalars.contains(where: {
isLetter($0) && matchesScript($0, regularExpression: latinScriptExpression)
}) {
return .english
}
return .other
}
private static func matchesScript(
_ scalar: Unicode.Scalar,
regularExpression: NSRegularExpression
) -> Bool {
let text = String(scalar)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return regularExpression.firstMatch(
in: text,
options: [],
range: range
) != nil
}
private static func isLetter(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.properties.generalCategory {
case .uppercaseLetter,
.lowercaseLetter,
.titlecaseLetter,
.modifierLetter,
.otherLetter:
return true
default:
return false
}
}
private static func saturatingIncrement(_ value: Int) -> Int {
min(KeyboardUsageCharacterCounts.maximumPerCategory, value + 1)
}
private static let hanScriptExpression = try! NSRegularExpression(
pattern: #"^\p{sc=Han}$"#
)
private static let latinScriptExpression = try! NSRegularExpression(
pattern: #"^\p{sc=Latin}$"#
)
}
public enum KeyboardUsageSessionClassification: Int, CaseIterable, Sendable {
case chineseOnly = 0
case englishOnly = 1
case mixedLanguage = 2
case otherOnly = 3
init(counts: KeyboardUsageCharacterCounts) {
switch (counts.chinese > 0, counts.english > 0) {
case (true, true):
self = .mixedLanguage
case (true, false):
self = .chineseOnly
case (false, true):
self = .englishOnly
case (false, false):
self = .otherOnly
}
}
func merging(_ counts: KeyboardUsageCharacterCounts) -> Self {
let hasChinese = self == .chineseOnly
|| self == .mixedLanguage
|| counts.chinese > 0
let hasEnglish = self == .englishOnly
|| self == .mixedLanguage
|| counts.english > 0
switch (hasChinese, hasEnglish) {
case (true, true):
return .mixedLanguage
case (true, false):
return .chineseOnly
case (false, true):
return .englishOnly
case (false, false):
return .otherOnly
}
}
}
public enum KeyboardUsageModelError: Error, Sendable {
case invalidDate
case invalidCount
case invalidSessionPartition
case invalidVersion
case invalidResponseCounts
case unknownField(String)
}
public struct KeyboardUsageSummary: Codable, Equatable, Sendable {
public let clientSummaryId: UUID
public let summaryDate: String
public let chineseCharacterCount: Int
public let englishCharacterCount: Int
public let otherCharacterCount: Int
public let inputSessionCount: Int
public let chineseOnlySessionCount: Int
public let englishOnlySessionCount: Int
public let mixedLanguageSessionCount: Int
public let otherOnlySessionCount: Int
public let appVersion: String
public let osVersion: String
public init(
clientSummaryId: UUID,
summaryDate: String,
chineseCharacterCount: Int,
englishCharacterCount: Int,
otherCharacterCount: Int,
inputSessionCount: Int,
chineseOnlySessionCount: Int,
englishOnlySessionCount: Int,
mixedLanguageSessionCount: Int,
otherOnlySessionCount: Int,
appVersion: String,
osVersion: String
) throws {
let characterCounts = [
chineseCharacterCount,
englishCharacterCount,
otherCharacterCount
]
let sessionCounts = [
inputSessionCount,
chineseOnlySessionCount,
englishOnlySessionCount,
mixedLanguageSessionCount,
otherOnlySessionCount
]
guard KeyboardUsageUTCDate.date(from: summaryDate) != nil else {
throw KeyboardUsageModelError.invalidDate
}
guard characterCounts.allSatisfy({
(0...KeyboardUsageCharacterCounts.maximumPerCategory).contains($0)
}),
sessionCounts.allSatisfy({ (0...100_000).contains($0) }) else {
throw KeyboardUsageModelError.invalidCount
}
guard chineseOnlySessionCount
+ englishOnlySessionCount
+ mixedLanguageSessionCount
+ otherOnlySessionCount == inputSessionCount,
characterCounts.reduce(0, +) >= inputSessionCount else {
throw KeyboardUsageModelError.invalidSessionPartition
}
guard AnalyticsEnvironment.isSafeVersion(appVersion),
AnalyticsEnvironment.isSafeVersion(osVersion) else {
throw KeyboardUsageModelError.invalidVersion
}
self.clientSummaryId = clientSummaryId
self.summaryDate = summaryDate
self.chineseCharacterCount = chineseCharacterCount
self.englishCharacterCount = englishCharacterCount
self.otherCharacterCount = otherCharacterCount
self.inputSessionCount = inputSessionCount
self.chineseOnlySessionCount = chineseOnlySessionCount
self.englishOnlySessionCount = englishOnlySessionCount
self.mixedLanguageSessionCount = mixedLanguageSessionCount
self.otherOnlySessionCount = otherOnlySessionCount
self.appVersion = appVersion
self.osVersion = osVersion
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case clientSummaryId
case summaryDate
case chineseCharacterCount
case englishCharacterCount
case otherCharacterCount
case inputSessionCount
case chineseOnlySessionCount
case englishOnlySessionCount
case mixedLanguageSessionCount
case otherOnlySessionCount
case appVersion
case osVersion
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
clientSummaryId: container.decode(UUID.self, forKey: .clientSummaryId),
summaryDate: container.decode(String.self, forKey: .summaryDate),
chineseCharacterCount: container.decode(Int.self, forKey: .chineseCharacterCount),
englishCharacterCount: container.decode(Int.self, forKey: .englishCharacterCount),
otherCharacterCount: container.decode(Int.self, forKey: .otherCharacterCount),
inputSessionCount: container.decode(Int.self, forKey: .inputSessionCount),
chineseOnlySessionCount: container.decode(
Int.self,
forKey: .chineseOnlySessionCount
),
englishOnlySessionCount: container.decode(
Int.self,
forKey: .englishOnlySessionCount
),
mixedLanguageSessionCount: container.decode(
Int.self,
forKey: .mixedLanguageSessionCount
),
otherOnlySessionCount: container.decode(
Int.self,
forKey: .otherOnlySessionCount
),
appVersion: container.decode(String.self, forKey: .appVersion),
osVersion: container.decode(String.self, forKey: .osVersion)
)
}
}
public struct KeyboardUsageUploadRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let summaries: [KeyboardUsageSummary]
public init(installationId: UUID, summaries: [KeyboardUsageSummary]) {
self.installationId = installationId
self.summaries = summaries
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case summaries
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
installationId = try container.decode(UUID.self, forKey: .installationId)
summaries = try container.decode([KeyboardUsageSummary].self, forKey: .summaries)
}
}
public struct KeyboardUsageUploadResponse: Codable, Equatable, Sendable {
public let accepted: Int
public let replayed: Int
public init(accepted: Int, replayed: Int) throws {
guard accepted >= 0, replayed >= 0 else {
throw KeyboardUsageModelError.invalidResponseCounts
}
self.accepted = accepted
self.replayed = replayed
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case accepted
case replayed
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
accepted: container.decode(Int.self, forKey: .accepted),
replayed: container.decode(Int.self, forKey: .replayed)
)
}
}
enum KeyboardUsageUTCDate {
static func string(from date: Date) -> String {
formatter.string(from: date)
}
static func date(from value: String) -> Date? {
guard value.utf8.count == 10 else { return nil }
return formatter.date(from: value)
}
static func oldestAcceptedDate(relativeTo today: String) -> String? {
guard let todayDate = date(from: today),
let oldest = calendar.date(byAdding: .day, value: -35, to: todayDate) else {
return nil
}
return string(from: oldest)
}
private static var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
formatter.isLenient = false
return formatter
}
private static var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar
}
}
private enum KeyboardUsageCodableAllowlist {
static func rejectUnknownKeys(
in decoder: Decoder,
allowed: Set<String>
) throws {
let container = try decoder.container(keyedBy: KeyboardUsageAnyCodingKey.self)
if let unknown = container.allKeys.first(where: {
!allowed.contains($0.stringValue)
}) {
throw KeyboardUsageModelError.unknownField(unknown.stringValue)
}
}
}
private struct KeyboardUsageAnyCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,547 @@
// KeyboardUsageUploadCoordinator.swift
// OSGKeyboard · Shared
//
// Uploads immutable daily summaries with the existing analytics transport,
// optional account bearer, bounded retry and cross-process leases.
import Foundation
public struct KeyboardUsageUploadConfiguration: Sendable {
public let endpoint: URL
public let maximumBatchCount: Int
public let globalLeaseDuration: TimeInterval
public let summaryLeaseDuration: TimeInterval
public let maximumBackoff: TimeInterval
public init(
endpoint: URL,
maximumBatchCount: Int = 50,
globalLeaseDuration: TimeInterval = 2 * 60,
summaryLeaseDuration: TimeInterval = 5 * 60,
maximumBackoff: TimeInterval = 6 * 60 * 60
) {
self.endpoint = endpoint
self.maximumBatchCount = min(50, max(1, maximumBatchCount))
self.globalLeaseDuration = max(60, globalLeaseDuration)
self.summaryLeaseDuration = max(60, summaryLeaseDuration)
self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff))
}
}
public actor KeyboardUsageUploadCoordinator {
private struct AuthorizationState {
var token: String?
var didRefresh = false
}
private let repository: KeyboardUsageRepository
private let configuration: KeyboardUsageUploadConfiguration
private let network: any AnalyticsNetworking
private let bearerProvider: (any AnalyticsBearerProviding)?
private let clock: any AnalyticsWallClock
private let uuidGenerator: any AnalyticsUUIDGenerating
private let random: any AnalyticsRandomGenerating
private let logger: any AnalyticsLogging
private var uploadInProgress = false
public init(
repository: KeyboardUsageRepository,
configuration: KeyboardUsageUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
self.repository = repository
self.configuration = configuration
self.network = network
self.bearerProvider = bearerProvider
self.clock = clock
self.uuidGenerator = uuidGenerator
self.random = random
self.logger = logger
}
public func uploadAvailableSummaries(maximumBatches: Int = 1) async {
guard !uploadInProgress, !Task.isCancelled else { return }
uploadInProgress = true
defer { uploadInProgress = false }
guard configuration.endpoint.scheme?.lowercased() == "https" else {
log(
outcome: .skipped,
count: 0,
category: .client
)
return
}
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
for _ in 0..<max(1, maximumBatches) {
guard !Task.isCancelled else { return }
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
guard !Task.isCancelled else {
await releaseForCancellation(
summaries: batch.summaries,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
if Task.isCancelled {
await releaseForCancellation(
summaries: batch.summaries,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
await scheduleRetry(
batch.summaries,
leaseID: batch.leaseID,
response: nil,
category: .authentication
)
await repository.releaseGlobalLease(ownerID: ownerID)
return
}
}
await process(
batch.summaries,
installationID: batch.installationID,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
guard !Task.isCancelled else { return }
}
}
private func process(
_ summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !summaries.isEmpty else { return }
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
guard await renewLease(
summaries: summaries,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(
summaries: summaries,
installationID: installationID,
token: authorization.token
)
} catch {
if Task.isCancelled {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
await scheduleRetry(
summaries,
leaseID: leaseID,
response: nil,
category: Self.networkCategory(for: error)
)
return
}
if response.statusCode == 401,
!authorization.didRefresh,
let bearerProvider {
authorization.didRefresh = true
let failedToken = authorization.token
do {
authorization.token = try await bearerProvider.refreshBearerToken(
afterUnauthorizedAccessToken: failedToken
)
guard await renewLease(
summaries: summaries,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
let refreshed = try await send(
summaries: summaries,
installationID: installationID,
token: authorization.token
)
await processResponse(
refreshed,
summaries: summaries,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
if Task.isCancelled {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
}
return
}
await processResponse(
response,
summaries: summaries,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
}
private func processResponse(
_ response: AnalyticsHTTPResponse,
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
switch response.statusCode {
case 200:
let decoded: KeyboardUsageUploadResponse
do {
decoded = try JSONDecoder().decode(
KeyboardUsageUploadResponse.self,
from: response.body
)
} catch {
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .decoding
)
return
}
guard decoded.accepted + decoded.replayed == summaries.count else {
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .countMismatch
)
return
}
let completed = await repository.complete(
rowIDs: summaries.map(\.rowID),
leaseID: leaseID
)
log(
outcome: completed ? .uploaded : .retryScheduled,
count: summaries.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: summaries),
category: completed ? nil : .storage
)
case 400, 409, 422:
if summaries.count == 1 {
await repository.quarantine(
rowIDs: [summaries[0].rowID],
leaseID: leaseID,
statusCode: response.statusCode
)
log(
outcome: .quarantined,
count: 1,
statusCode: response.statusCode,
attempt: summaries[0].attemptCount,
category: .client
)
return
}
let midpoint = summaries.count / 2
await process(
Array(summaries[..<midpoint]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
Array(summaries[midpoint...]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
case 401:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
case 408:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .timeout
)
case 429:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .rateLimited
)
case 500...599:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .server
)
case 400...499:
await repository.quarantine(
rowIDs: summaries.map(\.rowID),
leaseID: leaseID,
statusCode: response.statusCode
)
log(
outcome: .quarantined,
count: summaries.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: summaries),
category: .client
)
default:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .server
)
}
}
private func releaseForCancellation(
summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
ownerID: String
) async {
await repository.release(summaries: summaries, leaseID: leaseID)
await repository.releaseGlobalLease(ownerID: ownerID)
}
private func send(
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token, !token.isEmpty {
headers["Authorization"] = "Bearer \(token)"
}
return try await network.send(
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(
summaries: summaries,
installationID: installationID
)
)
)
}
private func renewLease(
summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
ownerID: String
) async -> Bool {
let renewed = await repository.renewLease(
ownerID: ownerID,
leaseID: leaseID,
configuration: configuration
)
guard renewed else {
await repository.release(summaries: summaries, leaseID: leaseID)
log(
outcome: .skipped,
count: summaries.count,
attempt: maximumAttempt(in: summaries),
category: .storage
)
return false
}
return true
}
private func scheduleRetry(
_ summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
response: AnalyticsHTTPResponse?,
category: AnalyticsUploadErrorCategory,
minimumDelay: TimeInterval = 0
) async {
let attempt = maximumAttempt(in: summaries) + 1
let exponent = min(attempt - 1, 16)
let ceiling = min(
configuration.maximumBackoff,
pow(2, Double(exponent))
)
let jitterMilliseconds = random.next(
upperBound: UInt64(max(0, ceiling * 1_000))
)
let jitter = TimeInterval(jitterMilliseconds) / 1_000
let retryAfter = response.flatMap(retryAfterDelay) ?? 0
let delay = min(
configuration.maximumBackoff,
max(minimumDelay, retryAfter, jitter)
)
await repository.scheduleRetry(
summaries: summaries,
leaseID: leaseID,
delay: delay
)
log(
outcome: .retryScheduled,
count: summaries.count,
statusCode: response?.statusCode,
attempt: attempt,
category: category
)
}
private func retryAfterDelay(
_ response: AnalyticsHTTPResponse
) -> TimeInterval? {
guard let value = response.header(named: "Retry-After")?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
if let seconds = TimeInterval(value) {
return min(configuration.maximumBackoff, max(0, seconds))
}
guard let date = Self.httpDateFormatter.date(from: value) else {
return nil
}
return min(
configuration.maximumBackoff,
max(0, date.timeIntervalSince(clock.now()))
)
}
private func maximumAttempt(
in summaries: [KeyboardUsageLeasedSummary]
) -> Int {
summaries.map(\.attemptCount).max() ?? 0
}
private func log(
outcome: AnalyticsUploadLogEntry.Outcome,
count: Int,
statusCode: Int? = nil,
attempt: Int = 0,
category: AnalyticsUploadErrorCategory?
) {
logger.log(
AnalyticsUploadLogEntry(
outcome: outcome,
eventCount: count,
statusCode: statusCode,
attempt: attempt,
errorCategory: category
)
)
}
private static func requestBody(
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","summaries":["#
.utf8
)
for index in summaries.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
}
body.append(summaries[index].payload)
}
body.append(Data("]}".utf8))
return body
}
private static func networkCategory(
for error: Error
) -> AnalyticsUploadErrorCategory {
guard let urlError = error as? URLError else { return .network }
switch urlError.code {
case .timedOut:
return .timeout
case .userAuthenticationRequired,
.userCancelledAuthentication:
return .authentication
default:
return .network
}
}
private static var httpDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
return formatter
}
}
@@ -14,18 +14,25 @@ public actor GatewayGrantCoordinator {
private let store: any GatewayGrantCredentialStore
private let session: URLSession
private let now: @Sendable () -> Date
private let refreshPath: String
private let accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
public init(
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
session: URLSession = .shared,
now: @escaping @Sendable () -> Date = Date.init
refreshPath: String = "v1/gateway/grants/refresh",
now: @escaping @Sendable () -> Date = Date.init,
accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing =
AppGroupManagedGatewayAccountAccessPolicy()
) {
self.baseURL = baseURL
self.store = store
self.session = session
self.refreshPath = refreshPath
self.now = now
self.accountAccessPolicy = accountAccessPolicy
}
/// Host-only integration point. The account access token authorizes grant
@@ -73,6 +80,12 @@ public actor GatewayGrantCoordinator {
for scope: ManagedGatewayCapability,
forceRefresh: Bool = false
) async throws -> String {
// A valid cached grant is not proof of a current account session.
// Check this before loading or refreshing credentials so signed-out
// callers cannot consume credits through stale Keychain state.
guard accountAccessPolicy.allowsAccountManagedAccess() else {
throw ManagedGatewayError.missingGrant
}
guard let credentials = try await store.load() else {
throw ManagedGatewayError.missingGrant
}
@@ -123,7 +136,7 @@ public actor GatewayGrantCoordinator {
let refreshToken: String
}
var request = URLRequest(url: endpoint("v1/gateway/grants/refresh"))
var request = URLRequest(url: endpoint(refreshPath))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(
@@ -230,6 +243,8 @@ enum ManagedGatewayHTTP {
switch code.lowercased() {
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
return .insufficientCredits
case "oobe_feature_already_used":
return .oobeFeatureAlreadyUsed
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
return .invalidGrant
default:
@@ -14,18 +14,25 @@ public protocol GatewayGrantCredentialStore: Sendable {
}
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
public enum Slot: String, Sendable {
case account = "scope-limited.active"
case oobe = "scope-limited.oobe"
}
public enum StoreError: Error, Equatable, Sendable {
case unexpectedStatus(OSStatus)
case invalidStoredValue
}
private static let service = "com.osgkeyboard.gateway-grant"
private static let account = "scope-limited.active"
private let slot: Slot
public init() {}
public init(slot: Slot = .account) {
self.slot = slot
}
public func load() async throws -> ManagedGatewayGrantCredentials? {
var query = Self.baseQuery
var query = baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
@@ -51,14 +58,14 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
let data = try Self.encoder.encode(credentials)
let updateStatus = SecItemUpdate(
Self.baseQuery as CFDictionary,
baseQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
var query = Self.baseQuery
var query = baseQuery
query[kSecValueData as String] = data
// The extension can refresh after reboot without making account
// credentials readable; this item contains only the limited grant.
@@ -73,17 +80,17 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
}
public func delete() async throws {
let status = SecItemDelete(Self.baseQuery as CFDictionary)
let status = SecItemDelete(baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw StoreError.unexpectedStatus(status)
}
}
private static var baseQuery: [String: Any] {
private var baseQuery: [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: slot.rawValue,
kSecAttrSynchronizable as String: kCFBooleanFalse!
]
#if os(macOS)
@@ -104,3 +111,23 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
return decoder
}
}
/// Dedicated extension-readable slot for anonymous onboarding grants. It can
/// never overwrite or load the signed-in account's normal managed grant.
public struct OOBEGatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
private let storage = GatewayGrantKeychainStore(slot: .oobe)
public init() {}
public func load() async throws -> ManagedGatewayGrantCredentials? {
try await storage.load()
}
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
try await storage.save(credentials)
}
public func delete() async throws {
try await storage.delete()
}
}
@@ -0,0 +1,40 @@
// ManagedGatewayAccountAccessPolicy.swift
// OSGKeyboard · Shared
//
// Device-local account-session gate for account-funded managed requests.
// The marker is intentionally non-secret and is never part of iCloud settings.
import Foundation
public protocol ManagedGatewayAccountAccessAuthorizing: Sendable {
func allowsAccountManagedAccess() -> Bool
}
/// Production policy shared by the host and keyboard extension. A cached grant
/// alone is insufficient: the host must also have confirmed an account session.
public struct AppGroupManagedGatewayAccountAccessPolicy:
ManagedGatewayAccountAccessAuthorizing,
@unchecked Sendable {
private let defaults: UserDefaults?
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
self.defaults = defaults
}
public func allowsAccountManagedAccess() -> Bool {
defaults?.bool(
forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable
) == true
}
}
/// Anonymous OOBE grants have their own bounded practice-session policy and do
/// not represent account-funded access. Tests may also inject this explicitly.
public struct UnrestrictedManagedGatewayAccountAccessPolicy:
ManagedGatewayAccountAccessAuthorizing {
public init() {}
public func allowsAccountManagedAccess() -> Bool {
true
}
}
@@ -19,6 +19,7 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
case translation
case editLastInput = "edit_last_input"
case aiQuestion = "ai_question"
case currentInformationQuestion = "current_information_question"
case clipboardTransform = "clipboard_transform"
case customSkill = "custom_skill"
case agentPlanning = "agent_planning"
@@ -30,6 +31,25 @@ public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
case oobe
}
/// Server-audited onboarding capability. This value is carried independently
/// from `taskKind` so billing and abuse policy never infer OOBE eligibility
/// from a generic clipboard or AI operation.
public enum ManagedGatewayOOBEFeature: String, Codable, CaseIterable, Sendable {
case voiceInput = "voice_input"
case clipboardTranslate = "clipboard_translate"
case clipboardReply = "clipboard_reply"
case askAI = "ask_ai"
public var requiredCapability: ManagedGatewayCapability {
switch self {
case .voiceInput:
return .polish
case .clipboardTranslate, .clipboardReply, .askAI:
return .assistant
}
}
}
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
public static let maximumAccessLifetime: TimeInterval = 5 * 60
@@ -85,6 +105,7 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
case scopeNotGranted(ManagedGatewayCapability)
case invalidGrant
case insufficientCredits
case oobeFeatureAlreadyUsed
case timeout
case server(code: String, status: Int, requestId: String?)
@@ -102,6 +123,8 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
return SharedL10n.string("managed.error.grantRejected")
case .insufficientCredits:
return SharedL10n.string("managed.error.insufficientCredits")
case .oobeFeatureAlreadyUsed:
return SharedL10n.string("managed.error.oobeFeatureAlreadyUsed")
case .timeout:
return SharedL10n.string("managed.error.timeout")
case .server(let code, let status, _):
@@ -115,15 +138,15 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
}
}
struct ManagedGatewayGrantTokenResponse: Decodable {
let grantId: String
let scopes: Set<ManagedGatewayCapability>
let accessToken: String
let accessExpiresAt: Date
let refreshToken: String
let refreshExpiresAt: Date
public struct ManagedGatewayGrantTokenResponse: Decodable, Sendable {
public let grantId: String
public let scopes: Set<ManagedGatewayCapability>
public let accessToken: String
public let accessExpiresAt: Date
public let refreshToken: String
public let refreshExpiresAt: Date
func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
public func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
ManagedGatewayGrantCredentials(
grantId: grantId,
scopes: scopes,
@@ -150,4 +173,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
let stream: Bool
let taskKind: ManagedGatewayTaskKind
let requestPurpose: ManagedGatewayRequestPurpose?
let oobeFeature: ManagedGatewayOOBEFeature?
}
@@ -0,0 +1,55 @@
// ManagedGatewayQuestionRouter.swift
// OSGKeyboard · Shared
//
// Deterministic, on-device routing for questions that cannot be answered
// reliably without current information. No prompt content is persisted.
import Foundation
public enum ManagedGatewayQuestionRouter {
public static func taskKind(
for question: String,
requestedTaskKind: ManagedGatewayTaskKind = .aiQuestion
) -> ManagedGatewayTaskKind {
guard requestedTaskKind == .aiQuestion else { return requestedTaskKind }
return requiresCurrentInformation(question)
? .currentInformationQuestion
: .aiQuestion
}
public static func requiresCurrentInformation(_ question: String) -> Bool {
let normalized = question
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return false }
if strongCurrentSignals.contains(where: normalized.contains) {
return true
}
let hasTemporalSignal = temporalSignals.contains(where: normalized.contains)
let hasCurrentSubject = currentSubjects.contains(where: normalized.contains)
return hasTemporalSignal && hasCurrentSubject
}
private static let strongCurrentSignals = [
"最新", "实时", "热点", "头条", "热搜", "要闻", "路况",
"breaking news", "latest news", "current events", "live score",
"stock price", "exchange rate", "traffic conditions"
]
private static let temporalSignals = [
"昨天", "今天", "今日", "今晚", "明天", "后天", "现在", "当前",
"此刻", "刚刚", "最近", "本周", "本周末", "本月", "今年",
"yesterday", "today", "tonight", "tomorrow", "right now",
"currently", "recent", "this week", "this weekend", "this month", "this year"
]
private static let currentSubjects = [
"新闻", "天气", "气温", "下雨", "台风", "股价", "股票", "大盘",
"汇率", "价格", "票价",
"比分", "赛果", "排名", "航班", "油价", "金价", "发生", "大事",
"news", "weather", "temperature", "price", "score", "ranking",
"flight", "forecast", "what happened"
]
}
@@ -51,10 +51,12 @@ public struct ManagedLLMClient: LLMClient {
public let capability: Capability
public let taskKind: ManagedGatewayTaskKind
public let requestPurpose: ManagedGatewayRequestPurpose?
public let oobeFeature: ManagedGatewayOOBEFeature?
public let requestTimeout: TimeInterval
private let baseURL: URL
private let grants: GatewayGrantCoordinator
private let oobeGrants: OOBEGatewayGrantCoordinator
private let session: URLSession
private let requestId: @Sendable () -> String
@@ -62,7 +64,9 @@ public struct ManagedLLMClient: LLMClient {
capability: Capability,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
grants: GatewayGrantCoordinator,
oobeGrants: OOBEGatewayGrantCoordinator? = nil,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared,
requestTimeout: TimeInterval = 15,
@@ -71,7 +75,12 @@ public struct ManagedLLMClient: LLMClient {
self.capability = capability
self.taskKind = taskKind ?? capability.defaultTaskKind
self.requestPurpose = requestPurpose
self.oobeFeature = oobeFeature
self.grants = grants
self.oobeGrants = oobeGrants ?? OOBEGatewayGrantCoordinator(
baseURL: baseURL,
session: session
)
self.baseURL = baseURL
self.session = session
self.requestTimeout = requestTimeout
@@ -189,7 +198,7 @@ public struct ManagedLLMClient: LLMClient {
do {
return try await bufferedAttempt(attempt.forcingRefresh())
} catch ManagedGatewayError.invalidGrant {
try? await grants.clearGrant()
try? await clearSelectedGrant()
throw ManagedGatewayError.invalidGrant
}
}
@@ -280,10 +289,34 @@ public struct ManagedLLMClient: LLMClient {
)
}
let token = try await grants.accessToken(
for: capability.grantScope,
forceRefresh: attempt.forceRefresh
)
let token: String
switch requestPurpose {
case .oobe:
guard let oobeFeature else {
throw ManagedGatewayError.server(
code: "missing_oobe_feature",
status: 400,
requestId: attempt.requestId
)
}
token = try await oobeGrants.accessToken(
for: capability.grantScope,
feature: oobeFeature,
forceRefresh: attempt.forceRefresh
)
case nil:
guard oobeFeature == nil else {
throw ManagedGatewayError.server(
code: "unexpected_oobe_feature",
status: 400,
requestId: attempt.requestId
)
}
token = try await grants.accessToken(
for: capability.grantScope,
forceRefresh: attempt.forceRefresh
)
}
let body = ManagedGatewayTextRequest(
input: trimmedInput,
context: boundedContext,
@@ -291,7 +324,8 @@ public struct ManagedLLMClient: LLMClient {
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
stream: stream,
taskKind: taskKind,
requestPurpose: requestPurpose
requestPurpose: requestPurpose,
oobeFeature: oobeFeature
)
var request = URLRequest(
@@ -307,6 +341,14 @@ public struct ManagedLLMClient: LLMClient {
return request
}
private func clearSelectedGrant() async throws {
if requestPurpose == .oobe {
try await oobeGrants.clearGrant()
} else {
try await grants.clearGrant()
}
}
static func payload(
from messages: [LLMRequest.Message]
) -> (input: String, context: String?) {
@@ -413,6 +455,9 @@ public struct ManagedLLMClient: LLMClient {
if ["insufficient_credits", "insufficient_balance"].contains(code) {
return .insufficientCredits
}
if code == "oobe_feature_already_used" {
return .oobeFeatureAlreadyUsed
}
if ["unauthorized", "gateway_grant_denied", "invalid_grant"].contains(code) {
return .invalidGrant
}
@@ -424,6 +469,7 @@ public enum ManagedGatewayLLMClientFactory {
public static func polish(
taskKind: ManagedGatewayTaskKind = .dictationPolish,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
@@ -432,6 +478,7 @@ public enum ManagedGatewayLLMClientFactory {
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: grants,
baseURL: baseURL,
session: session
@@ -440,6 +487,8 @@ public enum ManagedGatewayLLMClientFactory {
public static func ai(
taskKind: ManagedGatewayTaskKind = .aiQuestion,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
@@ -447,6 +496,8 @@ public enum ManagedGatewayLLMClientFactory {
ManagedLLMClient(
capability: .assistant,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: grants,
baseURL: baseURL,
session: session
@@ -0,0 +1,77 @@
// OOBEGatewayGrantCoordinator.swift
// OSGKeyboard · Shared
//
// Routes anonymous onboarding requests through a credential slot that is
// isolated from signed-in account grants.
import Foundation
public actor OOBEGatewayGrantCoordinator {
public static let allowedScopes: Set<ManagedGatewayCapability> = [
.polish,
.assistant
]
private let grants: GatewayGrantCoordinator
private let store: any GatewayGrantCredentialStore
private let now: @Sendable () -> Date
private let practiceSession: @Sendable () -> OOBEPracticeSession?
public init(
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
store: any GatewayGrantCredentialStore = OOBEGatewayGrantKeychainStore(),
session: URLSession = .shared,
now: @escaping @Sendable () -> Date = Date.init,
practiceSession: @escaping @Sendable () -> OOBEPracticeSession? = {
KeyboardSetupBridge.activeOOBEPracticeSession
}
) {
self.store = store
self.now = now
self.practiceSession = practiceSession
self.grants = GatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: session,
refreshPath: "v1/oobe/grants/refresh",
now: now,
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
)
}
/// Host-only provisioning handoff after App Attest succeeds.
public func install(_ credentials: ManagedGatewayGrantCredentials) async throws {
guard credentials.scopes == Self.allowedScopes,
credentials.hasUsableRefreshToken(at: now()),
credentials.refreshExpiresAt
<= credentials.receivedAt.addingTimeInterval(30 * 60 + 1) else {
throw ManagedGatewayError.invalidGrant
}
try await store.save(credentials)
}
public func accessToken(
for capability: ManagedGatewayCapability,
feature: ManagedGatewayOOBEFeature,
forceRefresh: Bool = false
) async throws -> String {
guard Self.allowedScopes.contains(capability),
feature.requiredCapability == capability else {
throw ManagedGatewayError.scopeNotGranted(capability)
}
guard let practice = practiceSession(),
practice.isActive(at: now()),
practice.expectedFeature == feature else {
try? await clearGrant()
throw ManagedGatewayError.missingGrant
}
return try await grants.accessToken(
for: capability,
forceRefresh: forceRefresh
)
}
public func clearGrant() async throws {
try await grants.clearGrant()
}
}