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
@@ -4,6 +4,7 @@
// The single HTTP exit for account, auth, and integrity traffic.
import Foundation
import OSGKeyboardShared
public protocol AccountHTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
@@ -36,6 +37,7 @@ public actor AccountAPIClient {
case integrityChallenge
case attest
case assert
case oobeGrant
var method: String {
switch self {
@@ -66,12 +68,15 @@ public actor AccountAPIClient {
return "/v1/integrity/attest"
case .assert:
return "/v1/integrity/assert"
case .oobeGrant:
return "/v1/oobe/grants"
}
}
}
private struct RefreshOperation {
let id: UUID
let failedAccessToken: String
let task: Task<AccountSession, Error>
}
@@ -85,6 +90,9 @@ public actor AccountAPIClient {
private var cachedSession: AccountSession?
private var didLoadSession = false
private var refreshOperation: RefreshOperation?
private var invalidationContinuations: [
UUID: AsyncStream<AccountSessionInvalidation>.Continuation
] = [:]
public init(
baseURL: URL = URL(string: "https://account.osglab.com")!,
@@ -117,6 +125,18 @@ public actor AccountAPIClient {
try await loadSessionIfNeeded()
}
public func sessionInvalidations() -> AsyncStream<AccountSessionInvalidation> {
let id = UUID()
return AsyncStream { continuation in
invalidationContinuations[id] = continuation
continuation.onTermination = { [weak self] _ in
Task {
await self?.removeInvalidationContinuation(id: id)
}
}
}
}
public func accessTokenForAuthorizedRequest() async throws -> String {
try await sessionForRequest().accessToken
}
@@ -184,7 +204,9 @@ public actor AccountAPIClient {
)
let retryResponse = try await send(retryRequest)
if retryResponse.response.statusCode == 401 {
try await clearSession()
try await invalidateSession(
ifAccessTokenMatches: replacement.accessToken
)
}
return try validatedData(retryResponse)
}
@@ -273,6 +295,25 @@ public actor AccountAPIClient {
return try decode(AppAssertionResponse.self, from: data).counter
}
public func requestOOBEGrant(
_ request: OOBEGrantRequest
) async throws -> ManagedGatewayGrantCredentials {
let data = try await perform(
endpoint: .oobeGrant,
body: try encode(request),
requiresSession: false
)
do {
let response = try Self.gatewayDecoder().decode(
ManagedGatewayGrantTokenResponse.self,
from: data
)
return response.credentials(receivedAt: now())
} catch {
throw AccountAPIError.decoding
}
}
private func perform(
endpoint: Endpoint,
body: Data?,
@@ -319,7 +360,9 @@ public actor AccountAPIClient {
)
let retryResponse = try await send(retryRequest)
if retryResponse.response.statusCode == 401 {
try await clearSession()
try await invalidateSession(
ifAccessTokenMatches: replacement.accessToken
)
}
return try validatedData(retryResponse)
}
@@ -345,7 +388,9 @@ public actor AccountAPIClient {
}
let nowSeconds = Int64(now().timeIntervalSince1970)
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
try await clearSession()
try await invalidateSession(
ifAccessTokenMatches: session.accessToken
)
throw AccountAPIError.unauthorized("The refresh token has expired.")
}
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
@@ -367,6 +412,7 @@ public actor AccountAPIClient {
let operation = RefreshOperation(
id: UUID(),
failedAccessToken: current.accessToken,
task: Task {
try await self.requestRefresh(using: current.refreshToken)
}
@@ -378,6 +424,18 @@ public actor AccountAPIClient {
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
do {
let replacement = try await operation.task.value
guard let current = try await loadSessionIfNeeded() else {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
throw AccountAPIError.sessionUnavailable
}
if current.accessToken != operation.failedAccessToken {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
return current
}
try await replaceSession(with: replacement)
if refreshOperation?.id == operation.id {
refreshOperation = nil
@@ -387,8 +445,14 @@ public actor AccountAPIClient {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
if let current = try? await loadSessionIfNeeded(),
current.accessToken != operation.failedAccessToken {
return current
}
if shouldClearSession(afterRefreshError: error) {
try? await clearSession()
try? await invalidateSession(
ifAccessTokenMatches: operation.failedAccessToken
)
}
throw error
}
@@ -451,6 +515,30 @@ public actor AccountAPIClient {
}
}
private func invalidateSession(ifAccessTokenMatches expectedAccessToken: String) async throws {
guard let current = try await loadSessionIfNeeded(),
current.accessToken == expectedAccessToken else {
return
}
do {
try await clearSession()
} catch {
publishSessionInvalidation()
throw error
}
publishSessionInvalidation()
}
private func publishSessionInvalidation() {
invalidationContinuations.values.forEach {
$0.yield(.expired)
}
}
private func removeInvalidationContinuation(id: UUID) {
invalidationContinuations[id] = nil
}
private func makeRequest(
endpoint: Endpoint,
body: Data?,
@@ -564,6 +652,29 @@ public actor AccountAPIClient {
throw AccountAPIError.decoding
}
}
private static func gatewayDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: value) {
return date
}
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
guard let date = standard.date(from: value) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid ISO-8601 date"
)
}
return date
}
return decoder
}
}
extension Data {
@@ -166,6 +166,31 @@ public struct AppAttestAssertion: Codable, Equatable, Sendable {
}
}
/// Anonymous OOBE grant request. It intentionally contains no account session,
/// requested scopes, or mutable feature claim; the server fixes policy to
/// `polish` + `ai` after validating App Attest.
public struct OOBEGrantRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let keyId: String
public let challengeId: UUID
public let challenge: String
public let assertion: String
public init(
installationId: UUID,
keyId: String,
challengeId: UUID,
challenge: String,
assertion: String
) {
self.installationId = installationId
self.keyId = keyId
self.challengeId = challengeId
self.challenge = challenge
self.assertion = assertion
}
}
public enum AppAttestChallengePurpose: String, Codable, Sendable {
case attestation
case assertion
@@ -205,6 +230,10 @@ public protocol AppAttestKeyStateStoring: Sendable {
func clearAppAttestKeyState() async throws
}
public protocol OOBEInstallationIDStoring: Sendable {
func oobeInstallationID() async throws -> UUID
}
public enum AccountAPIError: Error, Equatable, Sendable {
case invalidRequest(String)
case unauthorized(String)
@@ -222,6 +251,10 @@ public enum AccountAPIError: Error, Equatable, Sendable {
case integrityUnavailable
}
public enum AccountSessionInvalidation: Sendable {
case expired
}
extension AccountAPIError: LocalizedError {
public var errorDescription: String? {
switch self {
@@ -128,6 +128,35 @@ public enum AppAttestCanonicalPayload {
return data
}
/// Matches the server's anonymous OOBE grant assertion payload byte for
/// byte. The final line feed is part of the signed UTF-8 value.
public static func oobeGrant(
challenge: String,
installationID: UUID,
keyID: String
) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
let canonicalChallenge = challengeBytes.base64URLEncodedString()
let payload = """
osg-app-attest-v1
purpose=oobe-gateway-grant
challenge=\(canonicalChallenge)
key_id=\(keyID)
installation_id=\(installationID.uuidString.lowercased())
scopes=ai,polish
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
grant_ttl_seconds=1800
access_ttl_seconds=300
"""
guard let data = payload.data(using: .utf8) else {
throw AccountAPIError.invalidResponse
}
return data
}
static func challengeHash(_ challenge: String) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
@@ -187,6 +216,15 @@ public actor DeviceIntegrityCoordinator {
try? await keyStateStore.clearAppAttestKeyState()
}
public func makeOOBEGrantRequest(
installationID: UUID
) async throws -> OOBEGrantRequest {
try await makeOOBEGrantRequest(
installationID: installationID,
allowsKeyRecovery: true
)
}
private func optionalDeviceCheckToken() async -> String? {
try? await makeDeviceCheckToken()
}
@@ -250,6 +288,47 @@ public actor DeviceIntegrityCoordinator {
)
}
private func makeOOBEGrantRequest(
installationID: UUID,
allowsKeyRecovery: Bool
) async throws -> OOBEGrantRequest {
guard appAttest.isSupported else {
throw AccountAPIError.integrityUnavailable
}
let keyID = try await registeredKeyId()
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .assertion,
keyId: keyID
)
let payload = try AppAttestCanonicalPayload.oobeGrant(
challenge: challenge.challenge,
installationID: installationID,
keyID: keyID
)
let assertion: Data
do {
assertion = try await appAttest.generateAssertion(
keyID,
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
)
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await makeOOBEGrantRequest(
installationID: installationID,
allowsKeyRecovery: false
)
} catch {
throw AccountAPIError.integrityUnavailable
}
return OOBEGrantRequest(
installationId: installationID,
keyId: keyID,
challengeId: challenge.challengeId,
challenge: challenge.challenge,
assertion: assertion.base64EncodedString()
)
}
private func registeredKeyId(
allowsKeyRecovery: Bool = true
) async throws -> String {
@@ -38,10 +38,14 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
}
}
public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring {
public actor HostPrivateAccountKeychain:
AccountSessionVault,
AppAttestKeyStateStoring,
OOBEInstallationIDStoring {
private enum Account {
static let session = "account.session"
static let appAttestKeyState = "integrity.app-attest-key-state"
static let oobeInstallationID = "oobe.installation-id"
}
let descriptor: HostPrivateAccountKeychainDescriptor
@@ -78,6 +82,15 @@ public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateS
try delete(account: Account.appAttestKeyState)
}
public func oobeInstallationID() async throws -> UUID {
if let existing = try read(UUID.self, account: Account.oobeInstallationID) {
return existing
}
let created = UUID()
try write(created, account: Account.oobeInstallationID)
return created
}
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
@@ -0,0 +1,42 @@
// OOBEGrantProvisioningCoordinator.swift
// OSGKeyboard · HostSupport
//
// Provisions anonymous, App-Attest-bound OOBE credentials without creating or
// mutating an AccountSession.
import Foundation
import OSGKeyboardShared
public actor OOBEGrantProvisioningCoordinator {
private let apiClient: AccountAPIClient
private let integrity: DeviceIntegrityCoordinator
private let installationIDs: any OOBEInstallationIDStoring
private let grants: OOBEGatewayGrantCoordinator
public init(
apiClient: AccountAPIClient,
integrity: DeviceIntegrityCoordinator,
installationIDs: any OOBEInstallationIDStoring,
grants: OOBEGatewayGrantCoordinator = OOBEGatewayGrantCoordinator()
) {
self.apiClient = apiClient
self.integrity = integrity
self.installationIDs = installationIDs
self.grants = grants
}
@discardableResult
public func provision() async throws -> ManagedGatewayGrantCredentials {
let installationID = try await installationIDs.oobeInstallationID()
let request = try await integrity.makeOOBEGrantRequest(
installationID: installationID
)
let credentials = try await apiClient.requestOOBEGrant(request)
try await grants.install(credentials)
return credentials
}
public func clear() async {
try? await grants.clearGrant()
}
}