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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user