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