feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
// AccountAPIClient.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// The single HTTP exit for account, auth, and integrity traffic.
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol AccountHTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
public final class URLSessionAccountHTTPTransport: AccountHTTPTransport, @unchecked Sendable {
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
}
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
public actor AccountAPIClient {
|
||||
private enum Endpoint {
|
||||
case appleSignIn
|
||||
case refresh
|
||||
case logout
|
||||
case account
|
||||
case updateAccount
|
||||
case deleteAccount
|
||||
case integrityChallenge
|
||||
case attest
|
||||
case assert
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .account:
|
||||
return "GET"
|
||||
case .updateAccount:
|
||||
return "PATCH"
|
||||
case .deleteAccount:
|
||||
return "DELETE"
|
||||
default:
|
||||
return "POST"
|
||||
}
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .appleSignIn:
|
||||
return "/v1/auth/apple"
|
||||
case .refresh:
|
||||
return "/v1/auth/refresh"
|
||||
case .logout:
|
||||
return "/v1/auth/logout"
|
||||
case .account, .updateAccount, .deleteAccount:
|
||||
return "/v1/account"
|
||||
case .integrityChallenge:
|
||||
return "/v1/integrity/challenges"
|
||||
case .attest:
|
||||
return "/v1/integrity/attest"
|
||||
case .assert:
|
||||
return "/v1/integrity/assert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RefreshOperation {
|
||||
let id: UUID
|
||||
let task: Task<AccountSession, Error>
|
||||
}
|
||||
|
||||
private let baseURL: URL
|
||||
private let transport: any AccountHTTPTransport
|
||||
private let sessionVault: any AccountSessionVault
|
||||
private let now: @Sendable () -> Date
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
private var cachedSession: AccountSession?
|
||||
private var didLoadSession = false
|
||||
private var refreshOperation: RefreshOperation?
|
||||
|
||||
public init(
|
||||
baseURL: URL = URL(string: "https://account.osglab.com")!,
|
||||
transport: any AccountHTTPTransport = URLSessionAccountHTTPTransport(),
|
||||
sessionVault: any AccountSessionVault,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.transport = transport
|
||||
self.sessionVault = sessionVault
|
||||
self.now = now
|
||||
self.encoder = JSONEncoder()
|
||||
self.encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func signInWithApple(_ request: AppleSignInRequest) async throws -> AccountSession {
|
||||
let data = try await perform(
|
||||
endpoint: .appleSignIn,
|
||||
body: try encode(request),
|
||||
requiresSession: false
|
||||
)
|
||||
let session = try decode(APIDataEnvelope<AccountSession>.self, from: data).data
|
||||
try await replaceSession(with: session)
|
||||
return session
|
||||
}
|
||||
|
||||
public func currentSession() async throws -> AccountSession? {
|
||||
try await loadSessionIfNeeded()
|
||||
}
|
||||
|
||||
public func accessTokenForAuthorizedRequest() async throws -> String {
|
||||
try await sessionForRequest().accessToken
|
||||
}
|
||||
|
||||
public func refreshAccessToken(
|
||||
afterUnauthorizedAccessToken failedToken: String
|
||||
) async throws -> String {
|
||||
try await refreshSession(afterUnauthorizedAccessToken: failedToken).accessToken
|
||||
}
|
||||
|
||||
public func account() async throws -> OSGAccount {
|
||||
let data = try await perform(endpoint: .account, body: nil, requiresSession: true)
|
||||
return try decode(APIDataEnvelope<OSGAccount>.self, from: data).data
|
||||
}
|
||||
|
||||
public func updateAccount(displayName: String) async throws -> OSGAccount {
|
||||
let data = try await perform(
|
||||
endpoint: .updateAccount,
|
||||
body: try encode(UpdateAccountProfileRequest(displayName: displayName)),
|
||||
requiresSession: true
|
||||
)
|
||||
return try decode(APIDataEnvelope<OSGAccount>.self, from: data).data
|
||||
}
|
||||
|
||||
public func authorizedResourceData(
|
||||
_ resource: AccountAuthorizedResource,
|
||||
body: Data? = nil
|
||||
) async throws -> Data {
|
||||
do {
|
||||
return try await performAuthorizedResource(
|
||||
resource,
|
||||
body: body
|
||||
)
|
||||
} catch {
|
||||
guard resource.method == "GET", Self.isTransient(error) else {
|
||||
throw error
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
return try await performAuthorizedResource(
|
||||
resource,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAuthorizedResource(
|
||||
_ resource: AccountAuthorizedResource,
|
||||
body: Data?
|
||||
) async throws -> Data {
|
||||
let session = try await sessionForRequest()
|
||||
let firstRequest = try makeAuthorizedResourceRequest(
|
||||
resource,
|
||||
body: body,
|
||||
accessToken: session.accessToken
|
||||
)
|
||||
let firstResponse = try await send(firstRequest)
|
||||
if firstResponse.response.statusCode == 401 {
|
||||
let replacement = try await refreshSession(
|
||||
afterUnauthorizedAccessToken: session.accessToken
|
||||
)
|
||||
let retryRequest = try makeAuthorizedResourceRequest(
|
||||
resource,
|
||||
body: body,
|
||||
accessToken: replacement.accessToken
|
||||
)
|
||||
let retryResponse = try await send(retryRequest)
|
||||
if retryResponse.response.statusCode == 401 {
|
||||
try await clearSession()
|
||||
}
|
||||
return try validatedData(retryResponse)
|
||||
}
|
||||
return try validatedData(firstResponse)
|
||||
}
|
||||
|
||||
public func logout() async throws {
|
||||
// Local sign-out is authoritative for the device. Revocation remains
|
||||
// best-effort so an offline server cannot leave credentials at rest.
|
||||
_ = try? await perform(endpoint: .logout, body: nil, requiresSession: true)
|
||||
try await clearSession()
|
||||
}
|
||||
|
||||
func deleteAccount(with request: DeleteAccountRequest) async throws {
|
||||
_ = try await perform(
|
||||
endpoint: .deleteAccount,
|
||||
body: try encode(request),
|
||||
requiresSession: true
|
||||
)
|
||||
try await clearSession()
|
||||
}
|
||||
|
||||
public func deleteAccount(
|
||||
identityToken: String,
|
||||
authorizationCode: String,
|
||||
nonce: String
|
||||
) async throws {
|
||||
try await deleteAccount(
|
||||
with: DeleteAccountRequest(
|
||||
identityToken: identityToken,
|
||||
authorizationCode: authorizationCode,
|
||||
nonce: nonce
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func issueAppAttestChallenge(
|
||||
purpose: AppAttestChallengePurpose,
|
||||
keyId: String
|
||||
) async throws -> AppAttestChallenge {
|
||||
let data = try await perform(
|
||||
endpoint: .integrityChallenge,
|
||||
body: try encode(AppAttestChallengeRequest(purpose: purpose, keyId: keyId)),
|
||||
requiresSession: false
|
||||
)
|
||||
return try decode(AppAttestChallenge.self, from: data)
|
||||
}
|
||||
|
||||
public func submitAttestation(
|
||||
challenge: AppAttestChallenge,
|
||||
keyId: String,
|
||||
attestationObject: Data
|
||||
) async throws {
|
||||
let request = AppAttestationRequest(
|
||||
challengeId: challenge.challengeId,
|
||||
challenge: challenge.challenge,
|
||||
keyId: keyId,
|
||||
attestationObject: attestationObject.base64EncodedString()
|
||||
)
|
||||
_ = try await perform(
|
||||
endpoint: .attest,
|
||||
body: try encode(request),
|
||||
requiresSession: false
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func submitAssertion(
|
||||
challenge: AppAttestChallenge,
|
||||
keyId: String,
|
||||
assertion: Data,
|
||||
clientDataHash: Data
|
||||
) async throws -> Int64 {
|
||||
let request = AppAssertionRequest(
|
||||
challengeId: challenge.challengeId,
|
||||
challenge: challenge.challenge,
|
||||
keyId: keyId,
|
||||
assertion: assertion.base64EncodedString(),
|
||||
clientDataHash: clientDataHash.base64URLEncodedString()
|
||||
)
|
||||
let data = try await perform(
|
||||
endpoint: .assert,
|
||||
body: try encode(request),
|
||||
requiresSession: false
|
||||
)
|
||||
return try decode(AppAssertionResponse.self, from: data).counter
|
||||
}
|
||||
|
||||
private func perform(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
requiresSession: Bool
|
||||
) async throws -> Data {
|
||||
do {
|
||||
return try await performOnce(
|
||||
endpoint: endpoint,
|
||||
body: body,
|
||||
requiresSession: requiresSession
|
||||
)
|
||||
} catch {
|
||||
guard endpoint.method == "GET", Self.isTransient(error) else {
|
||||
throw error
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
return try await performOnce(
|
||||
endpoint: endpoint,
|
||||
body: body,
|
||||
requiresSession: requiresSession
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func performOnce(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
requiresSession: Bool
|
||||
) async throws -> Data {
|
||||
let session = requiresSession ? try await sessionForRequest() : nil
|
||||
let firstRequest = try makeRequest(
|
||||
endpoint: endpoint,
|
||||
body: body,
|
||||
accessToken: session?.accessToken
|
||||
)
|
||||
let firstResponse = try await send(firstRequest)
|
||||
|
||||
if requiresSession, firstResponse.response.statusCode == 401, let session {
|
||||
let replacement = try await refreshSession(afterUnauthorizedAccessToken: session.accessToken)
|
||||
let retryRequest = try makeRequest(
|
||||
endpoint: endpoint,
|
||||
body: body,
|
||||
accessToken: replacement.accessToken
|
||||
)
|
||||
let retryResponse = try await send(retryRequest)
|
||||
if retryResponse.response.statusCode == 401 {
|
||||
try await clearSession()
|
||||
}
|
||||
return try validatedData(retryResponse)
|
||||
}
|
||||
|
||||
return try validatedData(firstResponse)
|
||||
}
|
||||
|
||||
private static func isTransient(_ error: Error) -> Bool {
|
||||
guard let error = error as? AccountAPIError else { return false }
|
||||
switch error {
|
||||
case .transport, .externalServiceUnavailable:
|
||||
return true
|
||||
case .server(let statusCode, _, _):
|
||||
return (500..<600).contains(statusCode)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionForRequest() async throws -> AccountSession {
|
||||
guard let session = try await loadSessionIfNeeded() else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
let nowSeconds = Int64(now().timeIntervalSince1970)
|
||||
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
|
||||
try await clearSession()
|
||||
throw AccountAPIError.unauthorized("The refresh token has expired.")
|
||||
}
|
||||
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
|
||||
return try await refreshSession(afterUnauthorizedAccessToken: session.accessToken)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountSession {
|
||||
guard let current = try await loadSessionIfNeeded() else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
if current.accessToken != failedToken {
|
||||
return current
|
||||
}
|
||||
if let refreshOperation {
|
||||
return try await finishRefresh(refreshOperation)
|
||||
}
|
||||
|
||||
let operation = RefreshOperation(
|
||||
id: UUID(),
|
||||
task: Task {
|
||||
try await self.requestRefresh(using: current.refreshToken)
|
||||
}
|
||||
)
|
||||
refreshOperation = operation
|
||||
return try await finishRefresh(operation)
|
||||
}
|
||||
|
||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
||||
do {
|
||||
let replacement = try await operation.task.value
|
||||
try await replaceSession(with: replacement)
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
return replacement
|
||||
} catch {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
if shouldClearSession(afterRefreshError: error) {
|
||||
try? await clearSession()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func requestRefresh(using refreshToken: String) async throws -> AccountSession {
|
||||
let request = try makeRequest(
|
||||
endpoint: .refresh,
|
||||
body: try encode(RefreshSessionRequest(refreshToken: refreshToken)),
|
||||
accessToken: nil
|
||||
)
|
||||
let response = try await send(request)
|
||||
let data = try validatedData(response)
|
||||
return try decode(APIDataEnvelope<AccountSession>.self, from: data).data
|
||||
}
|
||||
|
||||
private func shouldClearSession(afterRefreshError error: Error) -> Bool {
|
||||
guard let error = error as? AccountAPIError else { return false }
|
||||
switch error {
|
||||
case .refreshTokenReuse, .unauthorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSessionIfNeeded() async throws -> AccountSession? {
|
||||
if didLoadSession {
|
||||
return cachedSession
|
||||
}
|
||||
do {
|
||||
cachedSession = try await sessionVault.loadSession()
|
||||
didLoadSession = true
|
||||
return cachedSession
|
||||
} catch {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
private func replaceSession(with session: AccountSession) async throws {
|
||||
do {
|
||||
try await sessionVault.saveSession(session)
|
||||
cachedSession = session
|
||||
didLoadSession = true
|
||||
} catch {
|
||||
cachedSession = nil
|
||||
didLoadSession = true
|
||||
try? await sessionVault.clearSession()
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
private func clearSession() async throws {
|
||||
cachedSession = nil
|
||||
didLoadSession = true
|
||||
do {
|
||||
try await sessionVault.clearSession()
|
||||
} catch {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRequest(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
accessToken: String?
|
||||
) throws -> URLRequest {
|
||||
guard let url = URL(string: endpoint.path, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = endpoint.method
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
if let accessToken {
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func makeAuthorizedResourceRequest(
|
||||
_ resource: AccountAuthorizedResource,
|
||||
body: Data?,
|
||||
accessToken: String
|
||||
) throws -> URLRequest {
|
||||
guard let url = URL(string: resource.path, relativeTo: baseURL)?.absoluteURL,
|
||||
url.scheme == baseURL.scheme,
|
||||
url.host == baseURL.host else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = resource.method
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
if resource == .creditsBalance {
|
||||
// Credit checks must revalidate with the server instead of reusing
|
||||
// a URL cache entry whose age may exceed the UI freshness window.
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||
}
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func send(_ request: URLRequest) async throws -> (data: Data, response: HTTPURLResponse) {
|
||||
do {
|
||||
return try await transport.data(for: request)
|
||||
} catch let error as AccountAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw AccountAPIError.transport
|
||||
}
|
||||
}
|
||||
|
||||
private func validatedData(
|
||||
_ result: (data: Data, response: HTTPURLResponse)
|
||||
) throws -> Data {
|
||||
guard (200..<300).contains(result.response.statusCode) else {
|
||||
throw mapAPIError(statusCode: result.response.statusCode, data: result.data)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
private func mapAPIError(statusCode: Int, data: Data) -> AccountAPIError {
|
||||
let payload = try? decoder.decode(APIErrorEnvelope.self, from: data).error
|
||||
let legacyMessage = try? decoder.decode(LegacyAPIErrorEnvelope.self, from: data).error
|
||||
let code = payload?.code ?? "http_error"
|
||||
let message = payload?.message
|
||||
?? legacyMessage
|
||||
?? "The account service request failed."
|
||||
switch code {
|
||||
case "invalid_request":
|
||||
return .invalidRequest(message)
|
||||
case "unauthorized":
|
||||
return .unauthorized(message)
|
||||
case "refresh_token_reuse":
|
||||
return .refreshTokenReuse
|
||||
case "external_service_unavailable":
|
||||
return .externalServiceUnavailable(message)
|
||||
case "conflict":
|
||||
return .conflict(message)
|
||||
case "rate_limited":
|
||||
return .rateLimited(message)
|
||||
default:
|
||||
return .server(statusCode: statusCode, code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func encode<Value: Encodable>(_ value: Value) throws -> Data {
|
||||
do {
|
||||
return try encoder.encode(value)
|
||||
} catch {
|
||||
throw AccountAPIError.invalidRequest("The request could not be encoded.")
|
||||
}
|
||||
}
|
||||
|
||||
private func decode<Value: Decodable>(_ type: Value.Type, from data: Data) throws -> Value {
|
||||
do {
|
||||
return try decoder.decode(type, from: data)
|
||||
} catch {
|
||||
throw AccountAPIError.decoding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
func base64URLEncodedString() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
init?(base64URLEncoded value: String) {
|
||||
guard value.range(of: #"^[A-Za-z0-9_-]*$"#, options: .regularExpression) != nil else {
|
||||
return nil
|
||||
}
|
||||
let padding = String(repeating: "=", count: (4 - value.count % 4) % 4)
|
||||
let base64 = value
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
+ padding
|
||||
self.init(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// AccountModels.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Wire models for the host-private OSG account protocol.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AccountSession: Codable, Equatable, Sendable {
|
||||
public let accountId: UUID
|
||||
public let tokenType: String
|
||||
public let accessToken: String
|
||||
public let accessTokenExpiresAtEpochSeconds: Int64
|
||||
public let refreshToken: String
|
||||
public let refreshTokenExpiresAtEpochSeconds: Int64
|
||||
|
||||
public init(
|
||||
accountId: UUID,
|
||||
tokenType: String,
|
||||
accessToken: String,
|
||||
accessTokenExpiresAtEpochSeconds: Int64,
|
||||
refreshToken: String,
|
||||
refreshTokenExpiresAtEpochSeconds: Int64
|
||||
) {
|
||||
self.accountId = accountId
|
||||
self.tokenType = tokenType
|
||||
self.accessToken = accessToken
|
||||
self.accessTokenExpiresAtEpochSeconds = accessTokenExpiresAtEpochSeconds
|
||||
self.refreshToken = refreshToken
|
||||
self.refreshTokenExpiresAtEpochSeconds = refreshTokenExpiresAtEpochSeconds
|
||||
}
|
||||
}
|
||||
|
||||
public struct OSGAccount: Codable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let createdAtEpochSeconds: Int64
|
||||
public let displayName: String?
|
||||
|
||||
public init(id: UUID, createdAtEpochSeconds: Int64, displayName: String? = nil) {
|
||||
self.id = id
|
||||
self.createdAtEpochSeconds = createdAtEpochSeconds
|
||||
self.displayName = displayName
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateAccountProfileRequest: Codable, Equatable, Sendable {
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// Exact authenticated resources exposed to the host account center. Keeping
|
||||
/// this closed prevents callers from forwarding account tokens to arbitrary
|
||||
/// paths or origins.
|
||||
public enum AccountAuthorizedResource: Sendable, Equatable {
|
||||
case creditsBalance
|
||||
case referralProfile
|
||||
case referralCampaigns
|
||||
case referrals(limit: Int)
|
||||
case createReferralCode
|
||||
case redeemReferral
|
||||
case storeKitProducts
|
||||
case storeKitTransactions(limit: Int, cursor: String?)
|
||||
case submitStoreKitTransaction
|
||||
case revokeGatewayGrant(UUID)
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .createReferralCode, .redeemReferral, .submitStoreKitTransaction:
|
||||
return "POST"
|
||||
case .revokeGatewayGrant:
|
||||
return "DELETE"
|
||||
default:
|
||||
return "GET"
|
||||
}
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .creditsBalance:
|
||||
return "/v1/credits/balance"
|
||||
case .referralProfile:
|
||||
return "/v1/referrals/me"
|
||||
case .referralCampaigns:
|
||||
return "/v1/referrals/campaigns"
|
||||
case .referrals(let limit):
|
||||
return "/v1/referrals?limit=\(min(max(limit, 1), 100))"
|
||||
case .createReferralCode:
|
||||
return "/v1/referrals/code"
|
||||
case .redeemReferral:
|
||||
return "/v1/referrals/redeem"
|
||||
case .storeKitProducts:
|
||||
return "/v1/storekit/products"
|
||||
case .storeKitTransactions(let limit, let cursor):
|
||||
let boundedLimit = min(max(limit, 1), 100)
|
||||
var components = URLComponents()
|
||||
components.path = "/v1/storekit/transactions"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "limit", value: String(boundedLimit))
|
||||
]
|
||||
if let cursor {
|
||||
components.queryItems?.append(
|
||||
URLQueryItem(name: "cursor", value: cursor)
|
||||
)
|
||||
}
|
||||
return components.string
|
||||
?? "/v1/storekit/transactions?limit=\(boundedLimit)"
|
||||
case .submitStoreKitTransaction:
|
||||
return "/v1/storekit/transactions"
|
||||
case .revokeGatewayGrant(let grantId):
|
||||
return "/v1/gateway/grants/\(grantId.uuidString.lowercased())"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppleSignInCredential: Equatable, Sendable {
|
||||
public let identityToken: String
|
||||
public let authorizationCode: String
|
||||
|
||||
public init(identityToken: String, authorizationCode: String) {
|
||||
self.identityToken = identityToken
|
||||
self.authorizationCode = authorizationCode
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppleSignInNonce: Equatable, Sendable {
|
||||
public let rawValue: String
|
||||
public let sha256Hex: String
|
||||
|
||||
public init(rawValue: String, sha256Hex: String) {
|
||||
self.rawValue = rawValue
|
||||
self.sha256Hex = sha256Hex
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppleSignInRequest: Codable, Equatable, Sendable {
|
||||
public let identityToken: String
|
||||
public let authorizationCode: String
|
||||
public let nonce: String
|
||||
public let displayName: String?
|
||||
public let deviceCheckToken: String?
|
||||
public let appAttest: AppAttestAssertion?
|
||||
|
||||
public init(
|
||||
identityToken: String,
|
||||
authorizationCode: String,
|
||||
nonce: String,
|
||||
displayName: String? = nil,
|
||||
deviceCheckToken: String?,
|
||||
appAttest: AppAttestAssertion?
|
||||
) {
|
||||
self.identityToken = identityToken
|
||||
self.authorizationCode = authorizationCode
|
||||
self.nonce = nonce
|
||||
self.displayName = displayName
|
||||
self.deviceCheckToken = deviceCheckToken
|
||||
self.appAttest = appAttest
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppAttestAssertion: Codable, Equatable, Sendable {
|
||||
public let keyId: String
|
||||
public let challengeId: UUID
|
||||
public let challenge: String
|
||||
public let assertion: String
|
||||
|
||||
public init(keyId: String, challengeId: UUID, challenge: String, assertion: String) {
|
||||
self.keyId = keyId
|
||||
self.challengeId = challengeId
|
||||
self.challenge = challenge
|
||||
self.assertion = assertion
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppAttestChallengePurpose: String, Codable, Sendable {
|
||||
case attestation
|
||||
case assertion
|
||||
}
|
||||
|
||||
public struct AppAttestChallenge: Codable, Equatable, Sendable {
|
||||
public let challengeId: UUID
|
||||
public let challenge: String
|
||||
public let expiresAtEpochSeconds: Int64
|
||||
|
||||
public init(challengeId: UUID, challenge: String, expiresAtEpochSeconds: Int64) {
|
||||
self.challengeId = challengeId
|
||||
self.challenge = challenge
|
||||
self.expiresAtEpochSeconds = expiresAtEpochSeconds
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppAttestKeyState: Codable, Equatable, Sendable {
|
||||
public let keyId: String
|
||||
public let isRegistered: Bool
|
||||
|
||||
public init(keyId: String, isRegistered: Bool) {
|
||||
self.keyId = keyId
|
||||
self.isRegistered = isRegistered
|
||||
}
|
||||
}
|
||||
|
||||
public protocol AccountSessionVault: Sendable {
|
||||
func loadSession() async throws -> AccountSession?
|
||||
func saveSession(_ session: AccountSession) async throws
|
||||
func clearSession() async throws
|
||||
}
|
||||
|
||||
public protocol AppAttestKeyStateStoring: Sendable {
|
||||
func loadAppAttestKeyState() async throws -> AppAttestKeyState?
|
||||
func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws
|
||||
func clearAppAttestKeyState() async throws
|
||||
}
|
||||
|
||||
public enum AccountAPIError: Error, Equatable, Sendable {
|
||||
case invalidRequest(String)
|
||||
case unauthorized(String)
|
||||
case refreshTokenReuse
|
||||
case externalServiceUnavailable(String)
|
||||
case conflict(String)
|
||||
case rateLimited(String)
|
||||
case server(statusCode: Int, code: String, message: String)
|
||||
case transport
|
||||
case invalidResponse
|
||||
case decoding
|
||||
case secureStorage
|
||||
case sessionUnavailable
|
||||
case appleAuthorization
|
||||
case integrityUnavailable
|
||||
}
|
||||
|
||||
extension AccountAPIError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidRequest(let message),
|
||||
.unauthorized(let message),
|
||||
.externalServiceUnavailable(let message),
|
||||
.conflict(let message),
|
||||
.rateLimited(let message):
|
||||
return message
|
||||
case .refreshTokenReuse:
|
||||
return "The session was revoked because refresh-token reuse was detected."
|
||||
case .server(_, _, let message):
|
||||
return message
|
||||
case .transport:
|
||||
return "The account service could not be reached."
|
||||
case .invalidResponse:
|
||||
return "The account service returned an invalid response."
|
||||
case .decoding:
|
||||
return "The account service response could not be decoded."
|
||||
case .secureStorage:
|
||||
return "The private account session could not be stored securely."
|
||||
case .sessionUnavailable:
|
||||
return "No account session is available."
|
||||
case .appleAuthorization:
|
||||
return "Sign in with Apple did not return valid credentials."
|
||||
case .integrityUnavailable:
|
||||
return "Device integrity verification is unavailable."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct APIDataEnvelope<Value: Codable & Sendable>: Codable, Sendable {
|
||||
let data: Value
|
||||
}
|
||||
|
||||
struct APIErrorEnvelope: Codable, Sendable {
|
||||
let error: APIErrorPayload
|
||||
}
|
||||
|
||||
struct APIErrorPayload: Codable, Sendable {
|
||||
let code: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct LegacyAPIErrorEnvelope: Codable, Sendable {
|
||||
let error: String
|
||||
}
|
||||
|
||||
struct RefreshSessionRequest: Codable, Sendable {
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
struct DeleteAccountRequest: Codable, Sendable {
|
||||
let identityToken: String
|
||||
let authorizationCode: String
|
||||
let nonce: String
|
||||
}
|
||||
|
||||
struct AppAttestChallengeRequest: Codable, Sendable {
|
||||
let purpose: AppAttestChallengePurpose
|
||||
let keyId: String
|
||||
}
|
||||
|
||||
struct AppAttestationRequest: Codable, Sendable {
|
||||
let challengeId: UUID
|
||||
let challenge: String
|
||||
let keyId: String
|
||||
let attestationObject: String
|
||||
}
|
||||
|
||||
struct AppAssertionRequest: Codable, Sendable {
|
||||
let challengeId: UUID
|
||||
let challenge: String
|
||||
let keyId: String
|
||||
let assertion: String
|
||||
let clientDataHash: String
|
||||
}
|
||||
|
||||
struct AppAssertionResponse: Codable, Sendable {
|
||||
let counter: Int64
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// DeviceIntegrity.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// DeviceCheck and App Attest adapters with server-bound canonical payloads.
|
||||
|
||||
import CryptoKit
|
||||
import DeviceCheck
|
||||
import Foundation
|
||||
|
||||
public protocol DeviceCheckTokenProviding: Sendable {
|
||||
var isSupported: Bool { get }
|
||||
func generateToken() async throws -> Data
|
||||
}
|
||||
|
||||
public protocol AppAttestProviding: Sendable {
|
||||
var isSupported: Bool { get }
|
||||
func generateKey() async throws -> String
|
||||
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data
|
||||
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data
|
||||
}
|
||||
|
||||
public final class SystemDeviceCheckProvider: DeviceCheckTokenProviding, @unchecked Sendable {
|
||||
private let device: DCDevice
|
||||
|
||||
public var isSupported: Bool {
|
||||
device.isSupported
|
||||
}
|
||||
|
||||
public init(device: DCDevice = .current) {
|
||||
self.device = device
|
||||
}
|
||||
|
||||
public func generateToken() async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
device.generateToken { data, error in
|
||||
if let data {
|
||||
continuation.resume(returning: data)
|
||||
} else {
|
||||
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class SystemAppAttestProvider: AppAttestProviding, @unchecked Sendable {
|
||||
private let service: DCAppAttestService
|
||||
|
||||
public var isSupported: Bool {
|
||||
service.isSupported
|
||||
}
|
||||
|
||||
public init(service: DCAppAttestService = .shared) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
public func generateKey() async throws -> String {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
service.generateKey { keyId, error in
|
||||
if let keyId {
|
||||
continuation.resume(returning: keyId)
|
||||
} else {
|
||||
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
service.attestKey(keyId, clientDataHash: clientDataHash) { object, error in
|
||||
if let object {
|
||||
continuation.resume(returning: object)
|
||||
} else {
|
||||
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
service.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
|
||||
if let assertion {
|
||||
continuation.resume(returning: assertion)
|
||||
} else {
|
||||
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceIntegrityEvidence: Equatable, Sendable {
|
||||
public let deviceCheckToken: String?
|
||||
public let appAttest: AppAttestAssertion?
|
||||
|
||||
public init(deviceCheckToken: String?, appAttest: AppAttestAssertion?) {
|
||||
self.deviceCheckToken = deviceCheckToken
|
||||
self.appAttest = appAttest
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppAttestCanonicalPayload {
|
||||
/// Matches `AppAttestCanonicalPayload.appleSignIn` on the account server.
|
||||
/// The final `\n` is part of the signed UTF-8 payload.
|
||||
public static func appleSignIn(
|
||||
challenge: String,
|
||||
credential: AppleSignInCredential,
|
||||
rawNonce: String
|
||||
) throws -> Data {
|
||||
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
}
|
||||
let canonicalChallenge = challengeBytes.base64URLEncodedString()
|
||||
let payload = """
|
||||
osg-app-attest-v1
|
||||
purpose=apple-sign-in
|
||||
challenge=\(canonicalChallenge)
|
||||
identity_token_sha256=\(digest(credential.identityToken))
|
||||
authorization_code_sha256=\(digest(credential.authorizationCode))
|
||||
nonce_sha256=\(digest(rawNonce))
|
||||
|
||||
"""
|
||||
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
|
||||
}
|
||||
return sha256(challengeBytes)
|
||||
}
|
||||
|
||||
static func sha256(_ data: Data) -> Data {
|
||||
Data(SHA256.hash(data: data))
|
||||
}
|
||||
|
||||
private static func digest(_ value: String) -> String {
|
||||
sha256(Data(value.utf8)).base64URLEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
public actor DeviceIntegrityCoordinator {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let deviceCheck: any DeviceCheckTokenProviding
|
||||
private let appAttest: any AppAttestProviding
|
||||
private let keyStateStore: any AppAttestKeyStateStoring
|
||||
|
||||
public init(
|
||||
apiClient: AccountAPIClient,
|
||||
deviceCheck: any DeviceCheckTokenProviding = SystemDeviceCheckProvider(),
|
||||
appAttest: any AppAttestProviding = SystemAppAttestProvider(),
|
||||
keyStateStore: any AppAttestKeyStateStoring
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.deviceCheck = deviceCheck
|
||||
self.appAttest = appAttest
|
||||
self.keyStateStore = keyStateStore
|
||||
}
|
||||
|
||||
public func evidenceForAppleSignIn(
|
||||
credential: AppleSignInCredential,
|
||||
rawNonce: String
|
||||
) async throws -> DeviceIntegrityEvidence {
|
||||
async let deviceCheckToken = optionalDeviceCheckToken()
|
||||
async let assertion = optionalAppleSignInAssertion(
|
||||
credential: credential,
|
||||
rawNonce: rawNonce
|
||||
)
|
||||
let evidence = await DeviceIntegrityEvidence(
|
||||
deviceCheckToken: deviceCheckToken,
|
||||
appAttest: assertion
|
||||
)
|
||||
if evidence.deviceCheckToken == nil,
|
||||
evidence.appAttest == nil,
|
||||
deviceCheck.isSupported || appAttest.isSupported {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
|
||||
public func clearLocalKeyState() async {
|
||||
try? await keyStateStore.clearAppAttestKeyState()
|
||||
}
|
||||
|
||||
private func optionalDeviceCheckToken() async -> String? {
|
||||
try? await makeDeviceCheckToken()
|
||||
}
|
||||
|
||||
private func optionalAppleSignInAssertion(
|
||||
credential: AppleSignInCredential,
|
||||
rawNonce: String
|
||||
) async -> AppAttestAssertion? {
|
||||
try? await makeAppleSignInAssertion(
|
||||
credential: credential,
|
||||
rawNonce: rawNonce
|
||||
)
|
||||
}
|
||||
|
||||
private func makeDeviceCheckToken() async throws -> String? {
|
||||
guard deviceCheck.isSupported else { return nil }
|
||||
do {
|
||||
return try await deviceCheck.generateToken().base64EncodedString()
|
||||
} catch {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAppleSignInAssertion(
|
||||
credential: AppleSignInCredential,
|
||||
rawNonce: String,
|
||||
allowsKeyRecovery: Bool = true
|
||||
) async throws -> AppAttestAssertion? {
|
||||
guard appAttest.isSupported else { return nil }
|
||||
let keyId = try await registeredKeyId()
|
||||
let challenge = try await apiClient.issueAppAttestChallenge(
|
||||
purpose: .assertion,
|
||||
keyId: keyId
|
||||
)
|
||||
let payload = try AppAttestCanonicalPayload.appleSignIn(
|
||||
challenge: challenge.challenge,
|
||||
credential: credential,
|
||||
rawNonce: rawNonce
|
||||
)
|
||||
let assertion: Data
|
||||
do {
|
||||
assertion = try await appAttest.generateAssertion(
|
||||
keyId,
|
||||
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
|
||||
)
|
||||
} catch where allowsKeyRecovery {
|
||||
try? await keyStateStore.clearAppAttestKeyState()
|
||||
return try await makeAppleSignInAssertion(
|
||||
credential: credential,
|
||||
rawNonce: rawNonce,
|
||||
allowsKeyRecovery: false
|
||||
)
|
||||
} catch {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
return AppAttestAssertion(
|
||||
keyId: keyId,
|
||||
challengeId: challenge.challengeId,
|
||||
challenge: challenge.challenge,
|
||||
assertion: assertion.base64EncodedString()
|
||||
)
|
||||
}
|
||||
|
||||
private func registeredKeyId(
|
||||
allowsKeyRecovery: Bool = true
|
||||
) async throws -> String {
|
||||
do {
|
||||
return try await registerKeyIfNeeded()
|
||||
} catch where allowsKeyRecovery {
|
||||
try? await keyStateStore.clearAppAttestKeyState()
|
||||
return try await registeredKeyId(allowsKeyRecovery: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerKeyIfNeeded() async throws -> String {
|
||||
var state: AppAttestKeyState
|
||||
do {
|
||||
if let existing = try await keyStateStore.loadAppAttestKeyState() {
|
||||
state = existing
|
||||
} else {
|
||||
let keyId = try await appAttest.generateKey()
|
||||
state = AppAttestKeyState(keyId: keyId, isRegistered: false)
|
||||
try await keyStateStore.saveAppAttestKeyState(state)
|
||||
}
|
||||
} catch let error as AccountAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
|
||||
if state.isRegistered {
|
||||
return state.keyId
|
||||
}
|
||||
|
||||
let challenge = try await apiClient.issueAppAttestChallenge(
|
||||
purpose: .attestation,
|
||||
keyId: state.keyId
|
||||
)
|
||||
let attestationObject: Data
|
||||
do {
|
||||
attestationObject = try await appAttest.attestKey(
|
||||
state.keyId,
|
||||
clientDataHash: AppAttestCanonicalPayload.challengeHash(challenge.challenge)
|
||||
)
|
||||
} catch {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
try await apiClient.submitAttestation(
|
||||
challenge: challenge,
|
||||
keyId: state.keyId,
|
||||
attestationObject: attestationObject
|
||||
)
|
||||
state = AppAttestKeyState(keyId: state.keyId, isRegistered: true)
|
||||
do {
|
||||
try await keyStateStore.saveAppAttestKeyState(state)
|
||||
} catch {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
return state.keyId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// HostPrivateAccountKeychain.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Main-app-only storage for OSG account sessions and App Attest key state.
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
public static let defaultService = "com.osgkeyboard.ios.account"
|
||||
public static let hostBundleIdentifier = "com.osgkeyboard.ios"
|
||||
|
||||
public let service: String
|
||||
public let accessGroup: String
|
||||
|
||||
public init(
|
||||
service: String = Self.defaultService,
|
||||
accessGroup: String
|
||||
) throws {
|
||||
let normalizedService = service.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedAccessGroup = accessGroup.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedService.isEmpty,
|
||||
normalizedAccessGroup.hasSuffix(".\(Self.hostBundleIdentifier)"),
|
||||
!normalizedAccessGroup.hasSuffix(".com.osgkeyboard.shared") else {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
self.service = normalizedService
|
||||
self.accessGroup = normalizedAccessGroup
|
||||
}
|
||||
|
||||
/// The prefix is the signed App Identifier Prefix, including or excluding
|
||||
/// its trailing period. It must come from host-app build configuration.
|
||||
public static func hostApplication(appIdentifierPrefix: String) throws -> Self {
|
||||
let prefix = appIdentifierPrefix.hasSuffix(".")
|
||||
? appIdentifierPrefix
|
||||
: "\(appIdentifierPrefix)."
|
||||
return try Self(accessGroup: "\(prefix)\(hostBundleIdentifier)")
|
||||
}
|
||||
}
|
||||
|
||||
public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring {
|
||||
private enum Account {
|
||||
static let session = "account.session"
|
||||
static let appAttestKeyState = "integrity.app-attest-key-state"
|
||||
}
|
||||
|
||||
let descriptor: HostPrivateAccountKeychainDescriptor
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(descriptor: HostPrivateAccountKeychainDescriptor) {
|
||||
self.descriptor = descriptor
|
||||
self.encoder = JSONEncoder()
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public func loadSession() async throws -> AccountSession? {
|
||||
try read(AccountSession.self, account: Account.session)
|
||||
}
|
||||
|
||||
public func saveSession(_ session: AccountSession) async throws {
|
||||
try write(session, account: Account.session)
|
||||
}
|
||||
|
||||
public func clearSession() async throws {
|
||||
try delete(account: Account.session)
|
||||
}
|
||||
|
||||
public func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
|
||||
try read(AppAttestKeyState.self, account: Account.appAttestKeyState)
|
||||
}
|
||||
|
||||
public func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws {
|
||||
try write(state, account: Account.appAttestKeyState)
|
||||
}
|
||||
|
||||
public func clearAppAttestKeyState() async throws {
|
||||
try delete(account: Account.appAttestKeyState)
|
||||
}
|
||||
|
||||
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
|
||||
var query = baseQuery(account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = result as? Data else {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(type, from: data)
|
||||
} catch {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
default:
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
private func write<Value: Encodable>(_ value: Value, account: String) throws {
|
||||
let data: Data
|
||||
do {
|
||||
data = try encoder.encode(value)
|
||||
} catch {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
|
||||
let query = baseQuery(account: account)
|
||||
let updateStatus = SecItemUpdate(
|
||||
query as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
guard updateStatus == errSecItemNotFound else {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData as String] = data
|
||||
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
guard SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess else {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(account: String) throws {
|
||||
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
private func baseQuery(account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: descriptor.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: descriptor.accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse!
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// SignInWithApple.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Raw-nonce generation and an injectable AuthenticationServices adapter.
|
||||
|
||||
import AuthenticationServices
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public protocol SecureRandomBytesGenerating: Sendable {
|
||||
func bytes(count: Int) throws -> Data
|
||||
}
|
||||
|
||||
public struct SystemSecureRandomBytesGenerator: SecureRandomBytesGenerating {
|
||||
public init() {}
|
||||
|
||||
public func bytes(count: Int) throws -> Data {
|
||||
guard count > 0 else {
|
||||
throw AccountAPIError.appleAuthorization
|
||||
}
|
||||
var data = Data(count: count)
|
||||
let status = data.withUnsafeMutableBytes { buffer in
|
||||
SecRandomCopyBytes(kSecRandomDefault, count, buffer.baseAddress!)
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
throw AccountAPIError.appleAuthorization
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
public protocol AppleSignInNonceGenerating: Sendable {
|
||||
func makeNonce() throws -> AppleSignInNonce
|
||||
}
|
||||
|
||||
public struct AppleSignInNonceGenerator: AppleSignInNonceGenerating {
|
||||
private let random: any SecureRandomBytesGenerating
|
||||
|
||||
public init(random: any SecureRandomBytesGenerating = SystemSecureRandomBytesGenerator()) {
|
||||
self.random = random
|
||||
}
|
||||
|
||||
public func makeNonce() throws -> AppleSignInNonce {
|
||||
let rawNonce = try random.bytes(count: 32).base64URLEncodedString()
|
||||
let digest = SHA256.hash(data: Data(rawNonce.utf8))
|
||||
let lowercaseHex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return AppleSignInNonce(rawValue: rawNonce, sha256Hex: lowercaseHex)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol AppleAuthorizationProviding: Sendable {
|
||||
func authorize(nonceSHA256: String) async throws -> AppleSignInCredential
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class AppleAuthorizationControllerProvider:
|
||||
NSObject,
|
||||
AppleAuthorizationProviding,
|
||||
ASAuthorizationControllerDelegate,
|
||||
ASAuthorizationControllerPresentationContextProviding,
|
||||
@unchecked Sendable {
|
||||
|
||||
public typealias PresentationAnchorProvider = @MainActor @Sendable () -> ASPresentationAnchor
|
||||
|
||||
private let presentationAnchorProvider: PresentationAnchorProvider
|
||||
private var continuation: CheckedContinuation<AppleSignInCredential, Error>?
|
||||
|
||||
public init(presentationAnchorProvider: @escaping PresentationAnchorProvider) {
|
||||
self.presentationAnchorProvider = presentationAnchorProvider
|
||||
}
|
||||
|
||||
public func authorize(nonceSHA256: String) async throws -> AppleSignInCredential {
|
||||
guard continuation == nil, !nonceSHA256.isEmpty else {
|
||||
throw AccountAPIError.appleAuthorization
|
||||
}
|
||||
let request = ASAuthorizationAppleIDProvider().createRequest()
|
||||
request.nonce = nonceSHA256
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
let controller = ASAuthorizationController(authorizationRequests: [request])
|
||||
controller.delegate = self
|
||||
controller.presentationContextProvider = self
|
||||
controller.performRequests()
|
||||
}
|
||||
}
|
||||
|
||||
public func authorizationController(
|
||||
controller: ASAuthorizationController,
|
||||
didCompleteWithAuthorization authorization: ASAuthorization
|
||||
) {
|
||||
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
|
||||
let identityTokenData = credential.identityToken,
|
||||
let authorizationCodeData = credential.authorizationCode,
|
||||
let identityToken = String(data: identityTokenData, encoding: .utf8),
|
||||
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
|
||||
!identityToken.isEmpty,
|
||||
!authorizationCode.isEmpty else {
|
||||
complete(with: .failure(AccountAPIError.appleAuthorization))
|
||||
return
|
||||
}
|
||||
complete(
|
||||
with: .success(
|
||||
AppleSignInCredential(
|
||||
identityToken: identityToken,
|
||||
authorizationCode: authorizationCode
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func authorizationController(
|
||||
controller: ASAuthorizationController,
|
||||
didCompleteWithError error: Error
|
||||
) {
|
||||
complete(with: .failure(AccountAPIError.appleAuthorization))
|
||||
}
|
||||
|
||||
public func presentationAnchor(
|
||||
for controller: ASAuthorizationController
|
||||
) -> ASPresentationAnchor {
|
||||
presentationAnchorProvider()
|
||||
}
|
||||
|
||||
private func complete(with result: Result<AppleSignInCredential, Error>) {
|
||||
let pending = continuation
|
||||
continuation = nil
|
||||
pending?.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
public actor AccountSignInCoordinator {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let appleAuthorization: any AppleAuthorizationProviding
|
||||
private let nonceGenerator: any AppleSignInNonceGenerating
|
||||
private let integrity: DeviceIntegrityCoordinator
|
||||
|
||||
public init(
|
||||
apiClient: AccountAPIClient,
|
||||
appleAuthorization: any AppleAuthorizationProviding,
|
||||
nonceGenerator: any AppleSignInNonceGenerating = AppleSignInNonceGenerator(),
|
||||
integrity: DeviceIntegrityCoordinator
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.appleAuthorization = appleAuthorization
|
||||
self.nonceGenerator = nonceGenerator
|
||||
self.integrity = integrity
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func signIn() async throws -> AccountSession {
|
||||
let nonce = try nonceGenerator.makeNonce()
|
||||
let credential = try await appleAuthorization.authorize(nonceSHA256: nonce.sha256Hex)
|
||||
let evidence = try await integrity.evidenceForAppleSignIn(
|
||||
credential: credential,
|
||||
rawNonce: nonce.rawValue
|
||||
)
|
||||
return try await apiClient.signInWithApple(
|
||||
AppleSignInRequest(
|
||||
identityToken: credential.identityToken,
|
||||
authorizationCode: credential.authorizationCode,
|
||||
nonce: nonce.rawValue,
|
||||
deviceCheckToken: evidence.deviceCheckToken,
|
||||
appAttest: evidence.appAttest
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
let nonce = try nonceGenerator.makeNonce()
|
||||
let credential = try await appleAuthorization.authorize(nonceSHA256: nonce.sha256Hex)
|
||||
try await apiClient.deleteAccount(
|
||||
with: DeleteAccountRequest(
|
||||
identityToken: credential.identityToken,
|
||||
authorizationCode: credential.authorizationCode,
|
||||
nonce: nonce.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@
|
||||
// audio capture and recognition. The keyboard extension receives
|
||||
// completed results through the Flow bridge instead of running ASR.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import Speech
|
||||
import Foundation
|
||||
import os
|
||||
import Speech
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
@@ -95,8 +95,8 @@ public enum AlibabaVocabularySync {
|
||||
"action": "create_vocabulary",
|
||||
"target_model": targetModel,
|
||||
"prefix": vocabularyPrefix,
|
||||
"vocabulary": vocabulary,
|
||||
] as [String: Any],
|
||||
"vocabulary": vocabulary
|
||||
] as [String: Any]
|
||||
]
|
||||
let data = try await postJSON(body, to: url, apiKey: apiKey, session: session)
|
||||
guard let id = parseVocabularyID(from: data) else {
|
||||
@@ -122,8 +122,8 @@ public enum AlibabaVocabularySync {
|
||||
"input": [
|
||||
"action": "update_vocabulary",
|
||||
"vocabulary_id": id,
|
||||
"vocabulary": vocabulary,
|
||||
] as [String: Any],
|
||||
"vocabulary": vocabulary
|
||||
] as [String: Any]
|
||||
]
|
||||
_ = try await postJSON(body, to: url, apiKey: apiKey, session: session)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
static func runTaskMessage(taskID: String, model: String, vocabularyID: String?) -> String {
|
||||
var parameters: [String: Any] = [
|
||||
"sample_rate": 16_000,
|
||||
"format": "pcm",
|
||||
"format": "pcm"
|
||||
]
|
||||
if let vocabularyID = vocabularyID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!vocabularyID.isEmpty {
|
||||
@@ -202,7 +202,7 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
"header": [
|
||||
"action": "run-task",
|
||||
"task_id": taskID,
|
||||
"streaming": "duplex",
|
||||
"streaming": "duplex"
|
||||
],
|
||||
"payload": [
|
||||
"task_group": "audio",
|
||||
@@ -210,8 +210,8 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
"function": "recognition",
|
||||
"model": model,
|
||||
"parameters": parameters,
|
||||
"input": [:] as [String: Any],
|
||||
],
|
||||
"input": [:] as [String: Any]
|
||||
]
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
@@ -225,9 +225,9 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
"header": [
|
||||
"action": "finish-task",
|
||||
"task_id": taskID,
|
||||
"streaming": "duplex",
|
||||
"streaming": "duplex"
|
||||
],
|
||||
"payload": ["input": [:] as [String: Any]],
|
||||
"payload": ["input": [:] as [String: Any]]
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
|
||||
@@ -42,7 +42,17 @@ extension CloudASRTranscribing {
|
||||
}
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
public static func make(
|
||||
store: any ConfigurationStore,
|
||||
session: URLSession = .shared,
|
||||
managedGrants: GatewayGrantCoordinator? = nil
|
||||
) -> CloudASRTranscribing {
|
||||
if store.credentialSource == .managed {
|
||||
return ManagedVolcengineASRClient(
|
||||
grants: managedGrants ?? GatewayGrantCoordinator(),
|
||||
session: session
|
||||
)
|
||||
}
|
||||
let providerId = store.asrProviderId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
let asrModel = store.asrModel.isEmpty
|
||||
@@ -229,8 +239,8 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
messages.append([
|
||||
"role": "user",
|
||||
"content": [
|
||||
["type": "input_text", "text": context],
|
||||
],
|
||||
["type": "input_text", "text": context]
|
||||
]
|
||||
])
|
||||
}
|
||||
messages.append([
|
||||
@@ -238,20 +248,20 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
"content": [
|
||||
[
|
||||
"type": "input_audio",
|
||||
"input_audio": ["data": dataURI],
|
||||
],
|
||||
],
|
||||
"input_audio": ["data": dataURI]
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
let parameters: [String: Any] = [
|
||||
"format": "wav",
|
||||
"sample_rate": "\(sampleRate)",
|
||||
"sample_rate": "\(sampleRate)"
|
||||
]
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": model,
|
||||
"input": ["messages": messages],
|
||||
"parameters": parameters,
|
||||
"parameters": parameters
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
@@ -304,22 +314,6 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
/// Groq / OpenRouter batch uploads cap around 30 s per request.
|
||||
private static let whisperCompatibleMaxDurationSeconds: TimeInterval = 30
|
||||
|
||||
init(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
session: URLSession,
|
||||
requestFormat: PromptCloudASRRequestFormat = .multipart
|
||||
) {
|
||||
self.providerId = providerId
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.session = session
|
||||
self.requestFormat = requestFormat
|
||||
}
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
@@ -422,8 +416,8 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
"model": model,
|
||||
"input_audio": [
|
||||
"data": wav.base64EncodedString(),
|
||||
"format": "wav",
|
||||
],
|
||||
"format": "wav"
|
||||
]
|
||||
]
|
||||
let prompt = dictionary.asrPromptBias(maxCharacters: 600)
|
||||
if !prompt.isEmpty {
|
||||
@@ -463,15 +457,15 @@ struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
}
|
||||
userContent.append([
|
||||
"type": "input_audio",
|
||||
"input_audio": ["data": dataURI],
|
||||
"input_audio": ["data": dataURI]
|
||||
])
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": model,
|
||||
"messages": [
|
||||
["role": "user", "content": userContent],
|
||||
["role": "user", "content": userContent]
|
||||
],
|
||||
"asr_options": ["language": "auto"],
|
||||
"asr_options": ["language": "auto"]
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
@@ -19,20 +19,28 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private let store: any ConfigurationStore
|
||||
private let session: URLSession
|
||||
private let localFallback: ASRService
|
||||
/// Optional account-managed path. A nil value preserves existing BYOK and
|
||||
/// provider-specific local-fallback selection unchanged.
|
||||
private let managedClient: (any CloudASRTranscribing)?
|
||||
private let managedGrants: GatewayGrantCoordinator?
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var client: CloudASRTranscribing?
|
||||
private var usesLocalFallback = false
|
||||
private var boundProviderId: String?
|
||||
private var boundClientSelection: String?
|
||||
private var cancelled = false
|
||||
private var streamingPipeline: StreamingUtterancePipeline?
|
||||
|
||||
public init(
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
session: URLSession = .shared,
|
||||
localFallback: ASRService? = nil
|
||||
localFallback: ASRService? = nil,
|
||||
managedClient: (any CloudASRTranscribing)? = nil,
|
||||
managedGrants: GatewayGrantCoordinator? = nil
|
||||
) {
|
||||
self.store = store
|
||||
self.session = session
|
||||
self.managedClient = managedClient
|
||||
self.managedGrants = managedGrants
|
||||
// `SpeechAnalyzerASR` is internal, so it can't appear in a public
|
||||
// default argument value — resolve the fallback in the body instead.
|
||||
self.localFallback = localFallback ?? SpeechAnalyzerASR()
|
||||
@@ -40,7 +48,10 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
|
||||
/// Whether Flow should prefer utterance-level true streaming for the bound provider.
|
||||
public var supportsUtteranceStreaming: Bool {
|
||||
CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|
||||
if store.credentialSource == .managed {
|
||||
return managedClient == nil || managedClient is CloudASRStreamingCapable
|
||||
}
|
||||
return CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|
||||
}
|
||||
|
||||
public func resetForNewUtterance() {
|
||||
@@ -246,9 +257,20 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private func bindClientIfNeeded() {
|
||||
let providerId = store.asrProviderId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
let credentialSource = store.credentialSource
|
||||
let selection = "\(credentialSource.rawValue):\(providerId)"
|
||||
lock.withLock {
|
||||
guard boundProviderId != providerId else { return }
|
||||
boundProviderId = providerId
|
||||
guard boundClientSelection != selection else { return }
|
||||
boundClientSelection = selection
|
||||
if credentialSource == .managed {
|
||||
usesLocalFallback = false
|
||||
client = managedClient ?? CloudASRClientFactory.make(
|
||||
store: store,
|
||||
session: session,
|
||||
managedGrants: managedGrants
|
||||
)
|
||||
return
|
||||
}
|
||||
usesLocalFallback = strategy == .localFallback
|
||||
client = usesLocalFallback
|
||||
? nil
|
||||
|
||||
@@ -12,6 +12,9 @@ import OSGKeyboardShared
|
||||
|
||||
enum CloudASRLogMetadata {
|
||||
static func describe(_ error: Error) -> String {
|
||||
if let managedError = error as? ManagedCloudASRError {
|
||||
return "category=\(managedError.stableCode)"
|
||||
}
|
||||
if let cloudError = error as? CloudASRError {
|
||||
switch cloudError {
|
||||
case .noAPIKey:
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
// ManagedVolcengineASRClient.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Account-server managed Volcengine ASR. The client reserves a one-shot
|
||||
// session over HTTP, streams raw PCM16LE over WebSocket, and consumes only
|
||||
// forwarded provider result payloads. BYOK clients remain independent.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
/// Supplies a short-lived gateway grant. Account sign-in and grant refresh
|
||||
/// remain outside the ASR transport so they can be wired without coupling.
|
||||
public protocol ManagedASRGrantProviding: Sendable {
|
||||
func accessToken(forceRefresh: Bool) async throws -> String
|
||||
}
|
||||
|
||||
public struct StaticManagedASRGrantProvider: ManagedASRGrantProviding {
|
||||
private let token: String
|
||||
|
||||
public init(token: String) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
public func accessToken(forceRefresh: Bool) async throws -> String {
|
||||
_ = forceRefresh
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
public struct GatewayCoordinatorASRGrantProvider: ManagedASRGrantProviding {
|
||||
private let coordinator: GatewayGrantCoordinator
|
||||
|
||||
public init(coordinator: GatewayGrantCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
public func accessToken(forceRefresh: Bool) async throws -> String {
|
||||
try await coordinator.accessToken(for: .asr, forceRefresh: forceRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable failures for account-managed ASR. Server messages are intentionally
|
||||
/// not retained because they are neither stable API identifiers nor safe logs.
|
||||
public enum ManagedCloudASRError: Error, LocalizedError, Sendable, Equatable {
|
||||
case invalidConfiguration
|
||||
case grantUnavailable
|
||||
case grantRejected
|
||||
case insufficientCredits
|
||||
case concurrencyLimit
|
||||
case sessionCreationFailed(status: Int, code: String)
|
||||
case sessionTransportFailed
|
||||
case connectTimeout
|
||||
case idleTimeout
|
||||
case websocketFailed(code: String)
|
||||
case invalidResult
|
||||
case emptyResult
|
||||
case batchFailed(status: Int, code: String)
|
||||
case batchTransportFailed
|
||||
|
||||
public var stableCode: String {
|
||||
switch self {
|
||||
case .invalidConfiguration: return "managed_asr_invalid_configuration"
|
||||
case .grantUnavailable: return "managed_asr_grant_unavailable"
|
||||
case .grantRejected: return "managed_asr_grant_rejected"
|
||||
case .insufficientCredits: return "managed_asr_insufficient_credits"
|
||||
case .concurrencyLimit: return "managed_asr_concurrency_limit"
|
||||
case .sessionCreationFailed: return "managed_asr_session_creation_failed"
|
||||
case .sessionTransportFailed: return "managed_asr_session_transport_failed"
|
||||
case .connectTimeout: return "managed_asr_connect_timeout"
|
||||
case .idleTimeout: return "managed_asr_idle_timeout"
|
||||
case .websocketFailed: return "managed_asr_websocket_failed"
|
||||
case .invalidResult: return "managed_asr_invalid_result"
|
||||
case .emptyResult: return "managed_asr_empty_result"
|
||||
case .batchFailed: return "managed_asr_batch_failed"
|
||||
case .batchTransportFailed: return "managed_asr_batch_transport_failed"
|
||||
}
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidConfiguration:
|
||||
return SharedL10n.string("managed.asr.error.invalidConfiguration")
|
||||
case .grantUnavailable:
|
||||
return SharedL10n.string("managed.error.grantUnavailable")
|
||||
case .grantRejected:
|
||||
return SharedL10n.string("managed.error.grantRejected")
|
||||
case .insufficientCredits:
|
||||
return SharedL10n.string("managed.error.insufficientCredits")
|
||||
case .concurrencyLimit:
|
||||
return SharedL10n.string("managed.asr.error.concurrencyLimit")
|
||||
case .sessionCreationFailed:
|
||||
return SharedL10n.string("managed.asr.error.sessionCreation")
|
||||
case .sessionTransportFailed:
|
||||
return SharedL10n.string("managed.asr.error.transport")
|
||||
case .connectTimeout:
|
||||
return SharedL10n.string("managed.asr.error.connectTimeout")
|
||||
case .idleTimeout:
|
||||
return SharedL10n.string("managed.asr.error.idleTimeout")
|
||||
case .websocketFailed:
|
||||
return SharedL10n.string("managed.asr.error.streaming")
|
||||
case .invalidResult:
|
||||
return SharedL10n.string("managed.asr.error.invalidResult")
|
||||
case .emptyResult:
|
||||
return SharedL10n.string("error.asr.noSpeech")
|
||||
case .batchFailed:
|
||||
return SharedL10n.string("managed.asr.error.batch")
|
||||
case .batchTransportFailed:
|
||||
return SharedL10n.string("managed.asr.error.transport")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ManagedASRWebSocketMessage: Sendable, Equatable {
|
||||
case data(Data)
|
||||
case string(String)
|
||||
case closed(code: Int, reason: String?)
|
||||
}
|
||||
|
||||
protocol ManagedASRHTTPClient: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
protocol ManagedASRWebSocket: Sendable {
|
||||
func resume()
|
||||
func ping() async throws
|
||||
func send(_ message: ManagedASRWebSocketMessage) async throws
|
||||
func receive() async throws -> ManagedASRWebSocketMessage
|
||||
func close()
|
||||
}
|
||||
|
||||
protocol ManagedASRWebSocketFactory: Sendable {
|
||||
func makeWebSocket(for request: URLRequest) -> any ManagedASRWebSocket
|
||||
}
|
||||
|
||||
private struct URLSessionManagedASRHTTPClient: ManagedASRHTTPClient {
|
||||
let session: URLSession
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ManagedCloudASRError.sessionTransportFailed
|
||||
}
|
||||
return (data, http)
|
||||
}
|
||||
}
|
||||
|
||||
private struct URLSessionManagedASRWebSocketFactory: ManagedASRWebSocketFactory {
|
||||
let session: URLSession
|
||||
|
||||
func makeWebSocket(for request: URLRequest) -> any ManagedASRWebSocket {
|
||||
URLSessionManagedASRWebSocket(task: session.webSocketTask(with: request))
|
||||
}
|
||||
}
|
||||
|
||||
private final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @unchecked Sendable {
|
||||
private let task: URLSessionWebSocketTask
|
||||
|
||||
init(task: URLSessionWebSocketTask) {
|
||||
self.task = task
|
||||
}
|
||||
|
||||
func resume() {
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
task.sendPing { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ message: ManagedASRWebSocketMessage) async throws {
|
||||
switch message {
|
||||
case .data(let data):
|
||||
try await task.send(.data(data))
|
||||
case .string(let string):
|
||||
try await task.send(.string(string))
|
||||
case .closed:
|
||||
throw ManagedCloudASRError.invalidConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
func receive() async throws -> ManagedASRWebSocketMessage {
|
||||
do {
|
||||
switch try await task.receive() {
|
||||
case .data(let data):
|
||||
return .data(data)
|
||||
case .string(let string):
|
||||
return .string(string)
|
||||
@unknown default:
|
||||
throw ManagedCloudASRError.invalidResult
|
||||
}
|
||||
} catch {
|
||||
let code = task.closeCode
|
||||
guard code != .invalid else { throw error }
|
||||
let reason = task.closeReason.flatMap { String(data: $0, encoding: .utf8) }
|
||||
return .closed(code: code.rawValue, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
task.cancel(with: .normalClosure, reason: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public struct ManagedVolcengineASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
|
||||
public static let defaultBaseURL = URL(string: "https://account.osglab.com")!
|
||||
|
||||
private let baseURL: URL
|
||||
private let grantProvider: any ManagedASRGrantProviding
|
||||
private let httpClient: any ManagedASRHTTPClient
|
||||
private let webSocketFactory: any ManagedASRWebSocketFactory
|
||||
private let estimatedDurationMillis: Int64
|
||||
private let connectTimeout: TimeInterval
|
||||
private let requestID: @Sendable () -> String
|
||||
|
||||
public init(
|
||||
baseURL: URL = ManagedVolcengineASRClient.defaultBaseURL,
|
||||
grantProvider: any ManagedASRGrantProviding,
|
||||
session: URLSession = .shared,
|
||||
estimatedDurationMillis: Int64 = 210_000,
|
||||
connectTimeout: TimeInterval = 8
|
||||
) {
|
||||
self.init(
|
||||
baseURL: baseURL,
|
||||
grantProvider: grantProvider,
|
||||
httpClient: URLSessionManagedASRHTTPClient(session: session),
|
||||
webSocketFactory: URLSessionManagedASRWebSocketFactory(session: session),
|
||||
estimatedDurationMillis: estimatedDurationMillis,
|
||||
connectTimeout: connectTimeout,
|
||||
requestID: { UUID().uuidString }
|
||||
)
|
||||
}
|
||||
|
||||
public init(
|
||||
baseURL: URL = ManagedVolcengineASRClient.defaultBaseURL,
|
||||
grants: GatewayGrantCoordinator,
|
||||
session: URLSession = .shared,
|
||||
estimatedDurationMillis: Int64 = 210_000,
|
||||
connectTimeout: TimeInterval = 8
|
||||
) {
|
||||
self.init(
|
||||
baseURL: baseURL,
|
||||
grantProvider: GatewayCoordinatorASRGrantProvider(coordinator: grants),
|
||||
session: session,
|
||||
estimatedDurationMillis: estimatedDurationMillis,
|
||||
connectTimeout: connectTimeout
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
baseURL: URL,
|
||||
grantProvider: any ManagedASRGrantProviding,
|
||||
httpClient: any ManagedASRHTTPClient,
|
||||
webSocketFactory: any ManagedASRWebSocketFactory,
|
||||
estimatedDurationMillis: Int64 = 210_000,
|
||||
connectTimeout: TimeInterval = 8,
|
||||
requestID: @escaping @Sendable () -> String = { UUID().uuidString }
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.grantProvider = grantProvider
|
||||
self.httpClient = httpClient
|
||||
self.webSocketFactory = webSocketFactory
|
||||
self.estimatedDurationMillis = estimatedDurationMillis
|
||||
self.connectTimeout = connectTimeout
|
||||
self.requestID = requestID
|
||||
}
|
||||
|
||||
public func prepare(dictionary: PersonalDictionary) async throws {
|
||||
_ = dictionary
|
||||
}
|
||||
|
||||
public func openStreamingSession(
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary,
|
||||
onPartial: @escaping @Sendable (String) -> Void
|
||||
) async throws -> any CloudASRStreamingSession {
|
||||
_ = dictionary
|
||||
try validateConfiguration()
|
||||
let requestID = requestID()
|
||||
var grant = try await resolvedGrant()
|
||||
let descriptor: SessionDescriptor
|
||||
do {
|
||||
descriptor = try await createSession(
|
||||
grant: grant,
|
||||
requestID: requestID,
|
||||
locale: locale
|
||||
)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch ManagedCloudASRError.grantRejected {
|
||||
grant = try await resolvedGrant(forceRefresh: true)
|
||||
descriptor = try await createSession(
|
||||
grant: grant,
|
||||
requestID: requestID,
|
||||
locale: locale
|
||||
)
|
||||
}
|
||||
|
||||
let socket: any ManagedASRWebSocket
|
||||
do {
|
||||
socket = try await connectWebSocket(
|
||||
descriptor: descriptor,
|
||||
grant: grant,
|
||||
requestID: requestID
|
||||
)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
grant = try await resolvedGrant(forceRefresh: true)
|
||||
socket = try await connectWebSocket(
|
||||
descriptor: descriptor,
|
||||
grant: grant,
|
||||
requestID: requestID
|
||||
)
|
||||
}
|
||||
|
||||
let live = ManagedVolcengineASRSession(
|
||||
socket: socket,
|
||||
maxFrameBytes: descriptor.maxFrameBytes,
|
||||
idleTimeout: TimeInterval(descriptor.idleTimeoutMillis) / 1_000,
|
||||
onPartial: onPartial
|
||||
)
|
||||
live.startReceiving()
|
||||
return live
|
||||
}
|
||||
|
||||
public func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
_ = locale
|
||||
_ = dictionary
|
||||
guard !samples.isEmpty else { throw ManagedCloudASRError.emptyResult }
|
||||
guard sampleRate == 16_000 else { throw ManagedCloudASRError.invalidConfiguration }
|
||||
try validateConfiguration()
|
||||
let pcm = CloudASRStreamingPCM.pcm16LE(samples: samples)
|
||||
let durationMillis = max(
|
||||
1,
|
||||
Int64((Double(samples.count) / Double(sampleRate) * 1_000).rounded(.up))
|
||||
)
|
||||
guard durationMillis <= 600_000 else { throw CloudASRError.audioTooLong }
|
||||
let requestID = requestID()
|
||||
let grant = try await resolvedGrant()
|
||||
do {
|
||||
return try await transcribeBatch(
|
||||
pcm: pcm,
|
||||
durationMillis: durationMillis,
|
||||
grant: grant,
|
||||
requestID: requestID
|
||||
)
|
||||
} catch ManagedCloudASRError.grantRejected {
|
||||
return try await transcribeBatch(
|
||||
pcm: pcm,
|
||||
durationMillis: durationMillis,
|
||||
grant: try await resolvedGrant(forceRefresh: true),
|
||||
requestID: requestID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func transcribeBatch(
|
||||
pcm: Data,
|
||||
durationMillis: Int64,
|
||||
grant: String,
|
||||
requestID: String
|
||||
) async throws -> String {
|
||||
let url = try endpointURL(path: "/v1/gateway/asr")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 90
|
||||
request.setValue("Bearer \(grant)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(requestID, forHTTPHeaderField: "X-Request-ID")
|
||||
request.setValue("\(durationMillis)", forHTTPHeaderField: "X-Audio-Duration-Ms")
|
||||
request.setValue("pcm", forHTTPHeaderField: "X-Audio-Format")
|
||||
request.setValue("raw", forHTTPHeaderField: "X-Audio-Codec")
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = pcm
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.data(for: request)
|
||||
} catch where ProviderToolCancellation.matches(error) {
|
||||
throw CancellationError()
|
||||
} catch let error as ManagedCloudASRError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ManagedCloudASRError.batchTransportFailed
|
||||
}
|
||||
guard (200..<300).contains(response.statusCode) else {
|
||||
throw Self.mapHTTPFailure(
|
||||
status: response.statusCode,
|
||||
data: data,
|
||||
phase: .batch
|
||||
)
|
||||
}
|
||||
return try Self.finalText(from: data)
|
||||
}
|
||||
|
||||
private func createSession(
|
||||
grant: String,
|
||||
requestID: String,
|
||||
locale: Locale
|
||||
) async throws -> SessionDescriptor {
|
||||
let url = try endpointURL(path: "/v1/gateway/asr/sessions")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = connectTimeout
|
||||
request.setValue("Bearer \(grant)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(requestID, forHTTPHeaderField: "X-Request-ID")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(
|
||||
CreateSessionRequest(
|
||||
language: Self.languageHint(from: locale),
|
||||
estimatedDurationMillis: estimatedDurationMillis
|
||||
)
|
||||
)
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.data(for: request)
|
||||
} catch where ProviderToolCancellation.matches(error) {
|
||||
throw CancellationError()
|
||||
} catch let error as ManagedCloudASRError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ManagedCloudASRError.sessionTransportFailed
|
||||
}
|
||||
guard response.statusCode == 201 else {
|
||||
throw Self.mapHTTPFailure(
|
||||
status: response.statusCode,
|
||||
data: data,
|
||||
phase: .session
|
||||
)
|
||||
}
|
||||
guard let descriptor = try? JSONDecoder().decode(SessionDescriptor.self, from: data),
|
||||
UUID(uuidString: descriptor.sessionId) != nil,
|
||||
descriptor.maxFrameBytes > 0,
|
||||
descriptor.idleTimeoutMillis > 0 else {
|
||||
throw ManagedCloudASRError.invalidResult
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
private func connectWebSocket(
|
||||
descriptor: SessionDescriptor,
|
||||
grant: String,
|
||||
requestID: String
|
||||
) async throws -> any ManagedASRWebSocket {
|
||||
let websocketURL = try resolvedWebSocketURL(path: descriptor.websocketPath)
|
||||
var request = URLRequest(url: websocketURL)
|
||||
request.timeoutInterval = connectTimeout
|
||||
request.setValue("Bearer \(grant)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(requestID, forHTTPHeaderField: "X-Request-ID")
|
||||
|
||||
let socket = webSocketFactory.makeWebSocket(for: request)
|
||||
socket.resume()
|
||||
do {
|
||||
try await managedWithTimeout(
|
||||
seconds: connectTimeout,
|
||||
timeoutError: .connectTimeout
|
||||
) {
|
||||
try await socket.ping()
|
||||
}
|
||||
return socket
|
||||
} catch is CancellationError {
|
||||
socket.close()
|
||||
throw CancellationError()
|
||||
} catch let error as ManagedCloudASRError {
|
||||
socket.close()
|
||||
throw error
|
||||
} catch {
|
||||
socket.close()
|
||||
throw ManagedCloudASRError.sessionTransportFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedGrant(forceRefresh: Bool = false) async throws -> String {
|
||||
let token: String
|
||||
do {
|
||||
token = try await grantProvider.accessToken(forceRefresh: forceRefresh)
|
||||
} catch where ProviderToolCancellation.matches(error) {
|
||||
throw CancellationError()
|
||||
} catch ManagedGatewayError.insufficientCredits {
|
||||
throw ManagedCloudASRError.insufficientCredits
|
||||
} catch ManagedGatewayError.invalidGrant {
|
||||
throw ManagedCloudASRError.grantRejected
|
||||
} catch ManagedGatewayError.scopeNotGranted(_) {
|
||||
throw ManagedCloudASRError.grantRejected
|
||||
} catch ManagedGatewayError.missingGrant {
|
||||
throw ManagedCloudASRError.grantRejected
|
||||
} catch {
|
||||
throw ManagedCloudASRError.grantUnavailable
|
||||
}
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw ManagedCloudASRError.grantUnavailable }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func validateConfiguration() throws {
|
||||
guard baseURL.scheme?.lowercased() == "https",
|
||||
baseURL.host != nil,
|
||||
estimatedDurationMillis > 0,
|
||||
estimatedDurationMillis <= 600_000,
|
||||
connectTimeout > 0 else {
|
||||
throw ManagedCloudASRError.invalidConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
private func endpointURL(path: String) throws -> URL {
|
||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw ManagedCloudASRError.invalidConfiguration
|
||||
}
|
||||
components.path = path
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
guard let url = components.url else {
|
||||
throw ManagedCloudASRError.invalidConfiguration
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func resolvedWebSocketURL(path: String) throws -> URL {
|
||||
// Relative same-origin paths prevent a compromised response from
|
||||
// redirecting the bearer grant to another host.
|
||||
guard path.hasPrefix("/"),
|
||||
!path.hasPrefix("//"),
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw ManagedCloudASRError.invalidResult
|
||||
}
|
||||
components.scheme = "wss"
|
||||
components.path = path
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
guard let url = components.url else { throw ManagedCloudASRError.invalidResult }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func languageHint(from locale: Locale) -> String? {
|
||||
let identifier = locale.identifier.lowercased()
|
||||
if identifier.hasPrefix("zh") { return "zh" }
|
||||
if identifier.hasPrefix("en") { return "en" }
|
||||
if identifier.hasPrefix("ja") { return "ja" }
|
||||
if identifier.hasPrefix("ko") { return "ko" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func finalText(from data: Data) throws -> String {
|
||||
var latestDisplay = ""
|
||||
var latestCommitted = ""
|
||||
var parsedAny = false
|
||||
for payload in resultPayloads(from: data) {
|
||||
parsedAny = true
|
||||
let display = VolcengineCloudASRClient.displayText(from: payload)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let committed = VolcengineCloudASRClient.committedText(from: payload)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !display.isEmpty { latestDisplay = display }
|
||||
if !committed.isEmpty { latestCommitted = committed }
|
||||
}
|
||||
guard parsedAny else { throw ManagedCloudASRError.invalidResult }
|
||||
let final = latestCommitted.isEmpty ? latestDisplay : latestCommitted
|
||||
guard !final.isEmpty else { throw ManagedCloudASRError.emptyResult }
|
||||
return final
|
||||
}
|
||||
|
||||
fileprivate static func resultPayloads(from data: Data) -> [Data] {
|
||||
if let frame = VolcengineFrame.parse(data),
|
||||
frame.messageType == .fullServerResponse {
|
||||
return [frame.payload]
|
||||
}
|
||||
var payloads: [Data] = []
|
||||
for bytes in [UInt8](data).split(separator: 0x0A, omittingEmptySubsequences: true) {
|
||||
let payload = Data(bytes)
|
||||
if (try? JSONSerialization.jsonObject(with: payload)) != nil {
|
||||
payloads.append(payload)
|
||||
}
|
||||
}
|
||||
return payloads
|
||||
}
|
||||
|
||||
private static func mapHTTPFailure(
|
||||
status: Int,
|
||||
data: Data,
|
||||
phase: HTTPPhase
|
||||
) -> ManagedCloudASRError {
|
||||
let code = gatewayErrorCode(from: data)
|
||||
switch code {
|
||||
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
|
||||
return .insufficientCredits
|
||||
case "asr_concurrency_limit":
|
||||
return .concurrencyLimit
|
||||
case "unauthorized", "gateway_grant_denied":
|
||||
return .grantRejected
|
||||
default:
|
||||
if status == 401 || status == 403 { return .grantRejected }
|
||||
if status == 402 || status == 422 { return .insufficientCredits }
|
||||
if status == 429 { return .concurrencyLimit }
|
||||
switch phase {
|
||||
case .session:
|
||||
return .sessionCreationFailed(status: status, code: code)
|
||||
case .batch:
|
||||
return .batchFailed(status: status, code: code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func gatewayErrorCode(from data: Data) -> String {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return "unknown"
|
||||
}
|
||||
if let code = json["code"] as? String, !code.isEmpty { return code }
|
||||
if let error = json["error"] as? [String: Any],
|
||||
let code = error["code"] as? String,
|
||||
!code.isEmpty {
|
||||
return code
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private enum HTTPPhase {
|
||||
case session
|
||||
case batch
|
||||
}
|
||||
|
||||
private struct CreateSessionRequest: Encodable {
|
||||
let format = "pcm"
|
||||
let codec = "raw"
|
||||
let sampleRate = 16_000
|
||||
let bits = 16
|
||||
let channels = 1
|
||||
let language: String?
|
||||
let estimatedDurationMillis: Int64
|
||||
}
|
||||
|
||||
private struct SessionDescriptor: Decodable {
|
||||
let sessionId: String
|
||||
let websocketPath: String
|
||||
let maxFrameBytes: Int
|
||||
let idleTimeoutMillis: Int64
|
||||
}
|
||||
}
|
||||
|
||||
private final class ManagedVolcengineASRSession: CloudASRStreamingSession, @unchecked Sendable {
|
||||
private struct State {
|
||||
var cancelled = false
|
||||
var failure: Error?
|
||||
var receiveClosed = false
|
||||
var endSent = false
|
||||
var latestDisplay = ""
|
||||
var latestCommitted = ""
|
||||
var sawResultPayload = false
|
||||
}
|
||||
|
||||
private let socket: any ManagedASRWebSocket
|
||||
private let maxFrameBytes: Int
|
||||
private let idleTimeout: TimeInterval
|
||||
private let onPartial: @Sendable (String) -> Void
|
||||
private let lock = OSAllocatedUnfairLock(initialState: State())
|
||||
private var receiveTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
socket: any ManagedASRWebSocket,
|
||||
maxFrameBytes: Int,
|
||||
idleTimeout: TimeInterval,
|
||||
onPartial: @escaping @Sendable (String) -> Void
|
||||
) {
|
||||
self.socket = socket
|
||||
self.maxFrameBytes = maxFrameBytes
|
||||
self.idleTimeout = idleTimeout
|
||||
self.onPartial = onPartial
|
||||
}
|
||||
|
||||
func startReceiving() {
|
||||
receiveTask = Task { [weak self] in
|
||||
await self?.receiveLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func append(samples: [Float]) async throws {
|
||||
try throwIfUnavailable()
|
||||
let pcm = CloudASRStreamingPCM.pcm16LE(samples: samples)
|
||||
guard !pcm.isEmpty else { return }
|
||||
var offset = 0
|
||||
while offset < pcm.count {
|
||||
try throwIfUnavailable()
|
||||
let end = min(offset + maxFrameBytes, pcm.count)
|
||||
do {
|
||||
try await socket.send(.data(pcm.subdata(in: offset..<end)))
|
||||
} catch where ProviderToolCancellation.matches(error) {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
throw ManagedCloudASRError.sessionTransportFailed
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
}
|
||||
|
||||
func finish() async throws -> String {
|
||||
try throwIfUnavailable()
|
||||
let shouldSendEnd = lock.withLock { state -> Bool in
|
||||
guard !state.endSent else { return false }
|
||||
state.endSent = true
|
||||
return true
|
||||
}
|
||||
if shouldSendEnd {
|
||||
do {
|
||||
// The server accepts only this exact control frame.
|
||||
try await socket.send(.string(#"{"type":"end"}"#))
|
||||
} catch where ProviderToolCancellation.matches(error) {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
throw ManagedCloudASRError.sessionTransportFailed
|
||||
}
|
||||
}
|
||||
|
||||
while true {
|
||||
try throwIfUnavailable()
|
||||
let snapshot = lock.withLock {
|
||||
($0.receiveClosed, $0.latestCommitted, $0.latestDisplay)
|
||||
}
|
||||
if snapshot.0 {
|
||||
let result = snapshot.1.isEmpty ? snapshot.2 : snapshot.1
|
||||
let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
cancelTransport()
|
||||
guard !trimmed.isEmpty else { throw ManagedCloudASRError.emptyResult }
|
||||
return trimmed
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
let changed = lock.withLock { state -> Bool in
|
||||
guard !state.cancelled else { return false }
|
||||
state.cancelled = true
|
||||
return true
|
||||
}
|
||||
guard changed else { return }
|
||||
cancelTransport()
|
||||
}
|
||||
|
||||
private func receiveLoop() async {
|
||||
while !Task.isCancelled {
|
||||
let message: ManagedASRWebSocketMessage
|
||||
do {
|
||||
message = try await managedWithTimeout(
|
||||
seconds: idleTimeout,
|
||||
timeoutError: .idleTimeout
|
||||
) {
|
||||
try await self.socket.receive()
|
||||
}
|
||||
} catch is CancellationError {
|
||||
if !lock.withLock({ $0.cancelled }) {
|
||||
publishFailure(CancellationError())
|
||||
}
|
||||
return
|
||||
} catch let error as ManagedCloudASRError {
|
||||
publishFailure(error)
|
||||
return
|
||||
} catch {
|
||||
publishFailure(ManagedCloudASRError.sessionTransportFailed)
|
||||
return
|
||||
}
|
||||
|
||||
switch message {
|
||||
case .data(let data):
|
||||
let payloads = ManagedVolcengineASRClient.resultPayloads(from: data)
|
||||
guard !payloads.isEmpty else {
|
||||
publishFailure(ManagedCloudASRError.invalidResult)
|
||||
return
|
||||
}
|
||||
for payload in payloads {
|
||||
consume(payload: payload)
|
||||
}
|
||||
case .string(let text):
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
json["type"] as? String == "gateway_error" else {
|
||||
publishFailure(ManagedCloudASRError.invalidResult)
|
||||
return
|
||||
}
|
||||
let code = json["code"] as? String ?? "unknown"
|
||||
let receivedOnlyEmptyResults = lock.withLock {
|
||||
$0.sawResultPayload
|
||||
&& $0.latestDisplay.isEmpty
|
||||
&& $0.latestCommitted.isEmpty
|
||||
}
|
||||
publishFailure(
|
||||
receivedOnlyEmptyResults
|
||||
? ManagedCloudASRError.emptyResult
|
||||
: ManagedCloudASRError.websocketFailed(code: code)
|
||||
)
|
||||
return
|
||||
case .closed(let code, _):
|
||||
if code == URLSessionWebSocketTask.CloseCode.normalClosure.rawValue {
|
||||
lock.withLock { $0.receiveClosed = true }
|
||||
} else {
|
||||
publishFailure(
|
||||
ManagedCloudASRError.websocketFailed(code: "close_\(code)")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func consume(payload: Data) {
|
||||
let display = VolcengineCloudASRClient.displayText(from: payload)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let committed = VolcengineCloudASRClient.committedText(from: payload)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let partial = lock.withLock { state -> String in
|
||||
state.sawResultPayload = true
|
||||
if !display.isEmpty { state.latestDisplay = display }
|
||||
if !committed.isEmpty { state.latestCommitted = committed }
|
||||
return state.latestDisplay
|
||||
}
|
||||
if !partial.isEmpty {
|
||||
onPartial(partial)
|
||||
}
|
||||
}
|
||||
|
||||
private func throwIfUnavailable() throws {
|
||||
let snapshot = lock.withLock { ($0.cancelled, $0.failure) }
|
||||
if snapshot.0 { throw CancellationError() }
|
||||
if let failure = snapshot.1 { throw failure }
|
||||
}
|
||||
|
||||
private func publishFailure(_ error: Error) {
|
||||
let shouldClose = lock.withLock { state -> Bool in
|
||||
guard state.failure == nil, !state.cancelled else { return false }
|
||||
state.failure = error
|
||||
return true
|
||||
}
|
||||
if shouldClose {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelTransport() {
|
||||
receiveTask?.cancel()
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func managedWithTimeout<T: Sendable>(
|
||||
seconds: TimeInterval,
|
||||
timeoutError: ManagedCloudASRError,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await operation()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(seconds))
|
||||
throw timeoutError
|
||||
}
|
||||
defer { group.cancelAll() }
|
||||
guard let value = try await group.next() else {
|
||||
throw timeoutError
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
|
||||
let language = OpenAIRealtimeTranscriptReducer.languageHint(from: locale)
|
||||
var transcription: [String: Any] = [
|
||||
"model": model,
|
||||
"delay": "low",
|
||||
"delay": "low"
|
||||
]
|
||||
if let language {
|
||||
transcription["language"] = language
|
||||
@@ -176,9 +176,9 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
|
||||
var input: [String: Any] = [
|
||||
"format": [
|
||||
"type": "audio/pcm",
|
||||
"rate": 24_000,
|
||||
"rate": 24_000
|
||||
],
|
||||
"transcription": transcription,
|
||||
"transcription": transcription
|
||||
]
|
||||
input["turn_detection"] = NSNull()
|
||||
let update: [String: Any] = [
|
||||
@@ -186,9 +186,9 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
|
||||
"session": [
|
||||
"type": "transcription",
|
||||
"audio": [
|
||||
"input": input,
|
||||
],
|
||||
],
|
||||
"input": input
|
||||
]
|
||||
]
|
||||
]
|
||||
try await sendJSON(update)
|
||||
let deadline = Date().addingTimeInterval(8)
|
||||
@@ -321,7 +321,7 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
|
||||
let audio = pcm.base64EncodedString()
|
||||
try await sendJSON([
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": audio,
|
||||
"audio": audio
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
// VAD sentence for definite polish-ready text (scheme A).
|
||||
"enable_nonstream": true,
|
||||
"end_window_size": 800,
|
||||
"force_to_speech_time": 1_000,
|
||||
"force_to_speech_time": 1_000
|
||||
]
|
||||
if let context = hotwordContext(dictionary: dictionary) {
|
||||
request["context"] = context
|
||||
@@ -121,9 +121,9 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
"rate": 16_000,
|
||||
"bits": 16,
|
||||
"channel": 1,
|
||||
"codec": "raw",
|
||||
"codec": "raw"
|
||||
],
|
||||
"request": request,
|
||||
"request": request
|
||||
]
|
||||
return try JSONSerialization.data(withJSONObject: payload)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
||||
if let results = json["result"] as? [[String: Any]] {
|
||||
return results.first
|
||||
}
|
||||
if json["text"] as? String != nil {
|
||||
if json["text"] is String {
|
||||
return json
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// these assets.
|
||||
|
||||
import Foundation
|
||||
import Speech
|
||||
import os
|
||||
import Speech
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
@@ -26,8 +26,14 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
|
||||
struct BundledManifest: Decodable {
|
||||
let version: String
|
||||
let bin_bytes: Int
|
||||
let binBytes: Int
|
||||
let identifier: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case binBytes = "bin_bytes"
|
||||
case identifier
|
||||
}
|
||||
}
|
||||
|
||||
private enum Storage {
|
||||
@@ -165,10 +171,10 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
)
|
||||
|
||||
Self.log(
|
||||
"preparing custom LM (\(manifest.bin_bytes) byte asset)… \(OSGDiag.memoryTag())"
|
||||
"preparing custom LM (\(manifest.binBytes) byte asset)… \(OSGDiag.memoryTag())"
|
||||
)
|
||||
OSGDiag.log(
|
||||
"clm.prepare.begin bytes=\(manifest.bin_bytes) \(OSGDiag.memoryTag())",
|
||||
"clm.prepare.begin bytes=\(manifest.binBytes) \(OSGDiag.memoryTag())",
|
||||
category: "asr"
|
||||
)
|
||||
try await Self.prepareLanguageModel(assetURL: assetURL, configuration: configuration)
|
||||
@@ -208,7 +214,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let contentHints = preset.contentHints.union([
|
||||
.customizedLanguage(modelConfiguration: lmConfiguration),
|
||||
.customizedLanguage(modelConfiguration: lmConfiguration)
|
||||
])
|
||||
return DictationTranscriber(
|
||||
locale: locale,
|
||||
@@ -268,7 +274,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
forResource: "OSGKeyboardCLM",
|
||||
withExtension: "bin",
|
||||
subdirectory: Storage.subdirectory
|
||||
),
|
||||
)
|
||||
]
|
||||
return candidates.compactMap { $0 }.first
|
||||
}
|
||||
@@ -285,7 +291,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
forResource: "compiled-manifest",
|
||||
withExtension: "json",
|
||||
subdirectory: Storage.subdirectory
|
||||
),
|
||||
)
|
||||
]
|
||||
guard let manifestURL = candidates.compactMap({ $0 }).first,
|
||||
let data = try? Data(contentsOf: manifestURL),
|
||||
@@ -348,7 +354,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static func fingerprint(for manifest: BundledManifest) -> String {
|
||||
"\(manifest.identifier)|\(manifest.version)|\(manifest.bin_bytes)"
|
||||
"\(manifest.identifier)|\(manifest.version)|\(manifest.binBytes)"
|
||||
}
|
||||
|
||||
private static func storedFingerprint() -> String? {
|
||||
@@ -366,8 +372,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
assetURL: URL,
|
||||
configuration: SFSpeechLanguageModel.Configuration
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
SFSpeechLanguageModel.prepareCustomLanguageModel(
|
||||
for: assetURL,
|
||||
configuration: configuration
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// the audio thread and read from the main thread (never UserDefaults
|
||||
// from the realtime tap — that caused cross-process crashes).
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
// lifecycle and recording ownership do not belong in the keyboard
|
||||
// extension or the platform-neutral Shared target.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Speech
|
||||
import Foundation
|
||||
import os
|
||||
import Speech
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Optional ¥30 consumable tip via StoreKit 2. Voluntary support only —
|
||||
// no feature gates, no App Group sync, no restore (Apple consumable rules).
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import StoreKit
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
|
||||
Reference in New Issue
Block a user