| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- import Foundation
- import Security
- // Remote contract: Docs/RemoteAPI.openapi.yaml
- // Keep the spec and these request/response models in sync.
- enum HTTPMethod: String {
- case get = "GET"
- case post = "POST"
- case put = "PUT"
- case delete = "DELETE"
- }
- struct APIEnvelope<T: Decodable>: Decodable {
- let code: Int
- let message: String
- let data: T?
- }
- private struct APIErrorEnvelope: Decodable {
- let code: Int
- let message: String
- }
- struct EmptyAPIData: Decodable { }
- enum APIError: LocalizedError {
- case invalidConfiguration
- case invalidResponse
- case secureConnection
- case unauthorized
- case server(code: Int, message: String)
- case transport(String)
- case decoding(String)
- case missingData
- case storageQuotaExceeded(requiredBytes: Int64, remainingBytes: Int64)
- var errorDescription: String? {
- switch self {
- case .invalidConfiguration: return "服务地址配置无效"
- case .invalidResponse: return "服务器返回了无效响应"
- case .secureConnection: return "无法安全连接登录服务器,请检查服务器 HTTPS 证书配置"
- case .unauthorized: return "登录状态已失效,请重新登录"
- case .server(_, let message): return message
- case .transport(let message): return "网络连接失败:\(message)"
- case .decoding(let message): return "服务器数据解析失败:\(message)"
- case .missingData: return "服务器响应缺少必要数据"
- case .storageQuotaExceeded(let requiredBytes, let remainingBytes):
- let formatter = ByteCountFormatter()
- formatter.countStyle = .file
- return "云端空间不足:需要 \(formatter.string(fromByteCount: requiredBytes)),剩余 \(formatter.string(fromByteCount: remainingBytes))"
- }
- }
- }
- struct AuthTokens: Codable, Sendable {
- let accessToken: String
- let refreshToken: String
- let expiresAt: Date
- }
- final class TokenStore: @unchecked Sendable {
- static let shared = TokenStore()
- private let service = "com.celestia.trace.authentication"
- private let account = "current-session"
- func load() -> AuthTokens? {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne
- ]
- var result: CFTypeRef?
- guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
- let data = result as? Data else { return nil }
- return try? JSONDecoder().decode(AuthTokens.self, from: data)
- }
- func save(_ tokens: AuthTokens) throws {
- let data = try JSONEncoder().encode(tokens)
- let baseQuery: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account
- ]
- let attributes: [String: Any] = [
- kSecValueData as String: data,
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- ]
- let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary)
- if status == errSecItemNotFound {
- var insert = baseQuery
- attributes.forEach { insert[$0.key] = $0.value }
- guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else {
- throw APIError.transport("无法安全保存登录凭据")
- }
- } else if status != errSecSuccess {
- throw APIError.transport("无法更新登录凭据")
- }
- }
- func clear() {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account
- ]
- SecItemDelete(query as CFDictionary)
- }
- }
- private struct RefreshPayload: Decodable {
- let token: String
- let refreshToken: String
- let expiresAt: Date
- }
- actor APIClient {
- static let shared = APIClient()
- private let baseURL: URL
- private let session: URLSession
- private let tokenStore: TokenStore
- init(
- baseURL: URL? = nil,
- session: URLSession? = nil,
- tokenStore: TokenStore = .shared
- ) {
- let configured = baseURL ?? (Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String).flatMap(URL.init(string:))
- self.baseURL = configured ?? URL(string: "https://api.ccdw.life/celestia-trace/v1")!
- if let session {
- self.session = session
- } else {
- let configuration = URLSessionConfiguration.default
- // Authentication is user-initiated. Fail promptly when the device is
- // offline or TLS negotiation fails instead of leaving the UI waiting.
- configuration.waitsForConnectivity = false
- configuration.timeoutIntervalForRequest = 15
- configuration.timeoutIntervalForResource = 60
- self.session = URLSession(configuration: configuration)
- }
- self.tokenStore = tokenStore
- }
- func hasStoredSession() -> Bool {
- tokenStore.load() != nil
- }
- func storeTokens(accessToken: String, refreshToken: String, expiresAt: Date) throws {
- try tokenStore.save(AuthTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt))
- }
- func clearSession() {
- tokenStore.clear()
- }
- func request<T: Decodable>(
- _ path: String,
- method: HTTPMethod = .get,
- body: Data? = nil,
- authenticated: Bool = false
- ) async throws -> T {
- let envelope: APIEnvelope<T> = try await execute(
- path,
- method: method,
- body: body,
- authenticated: authenticated,
- canRefresh: true
- )
- guard let data = envelope.data else { throw APIError.missingData }
- return data
- }
- func requestVoid(
- _ path: String,
- method: HTTPMethod,
- body: Data? = nil,
- authenticated: Bool = false
- ) async throws {
- let _: APIEnvelope<EmptyAPIData> = try await execute(
- path,
- method: method,
- body: body,
- authenticated: authenticated,
- canRefresh: true
- )
- }
- func upload<T: Decodable>(
- _ path: String,
- fileURL: URL,
- fileName: String,
- mimeType: String,
- fields: [String: String],
- progress: @escaping @Sendable (Double) -> Void
- ) async throws -> T {
- do {
- return try await performUpload(
- path,
- fileURL: fileURL,
- fileName: fileName,
- mimeType: mimeType,
- fields: fields,
- progress: progress
- )
- } catch APIError.unauthorized {
- try await refreshTokens()
- return try await performUpload(
- path,
- fileURL: fileURL,
- fileName: fileName,
- mimeType: mimeType,
- fields: fields,
- progress: progress
- )
- }
- }
- func download(_ path: String, to destinationURL: URL) async throws {
- do {
- try await performDownload(path, to: destinationURL)
- } catch APIError.unauthorized {
- try await refreshTokens()
- try await performDownload(path, to: destinationURL)
- }
- }
- private func execute<T: Decodable>(
- _ path: String,
- method: HTTPMethod,
- body: Data?,
- authenticated: Bool,
- canRefresh: Bool
- ) async throws -> APIEnvelope<T> {
- var request = try makeRequest(path: path, method: method, body: body)
- if authenticated {
- guard let accessToken = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- }
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await session.data(for: request)
- } catch let error as URLError where Self.isSecureConnectionError(error.code) {
- throw APIError.secureConnection
- } catch {
- throw APIError.transport(error.localizedDescription)
- }
- guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
- if http.statusCode == 401, authenticated, canRefresh {
- try await refreshTokens()
- return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false)
- }
- guard (200..<300).contains(http.statusCode) else {
- throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
- }
- let envelope: APIEnvelope<T>
- do {
- envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
- } catch {
- throw APIError.decoding(error.localizedDescription)
- }
- guard envelope.code == 0 else {
- throw APIError.server(code: envelope.code, message: envelope.message)
- }
- return envelope
- }
- private func refreshTokens() async throws {
- guard let current = tokenStore.load() else { throw APIError.unauthorized }
- let body = try JSONSerialization.data(withJSONObject: ["refreshToken": current.refreshToken])
- let envelope: APIEnvelope<RefreshPayload> = try await execute(
- "auth/refresh",
- method: .post,
- body: body,
- authenticated: false,
- canRefresh: false
- )
- guard let payload = envelope.data else { throw APIError.unauthorized }
- try tokenStore.save(AuthTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt))
- }
- private func performUpload<T: Decodable>(
- _ path: String,
- fileURL: URL,
- fileName: String,
- mimeType: String,
- fields: [String: String],
- progress: @escaping @Sendable (Double) -> Void
- ) async throws -> T {
- guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
- let boundary = "CelestiaBoundary-\(UUID().uuidString)"
- let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".upload")
- guard FileManager.default.createFile(atPath: temporaryURL.path, contents: nil),
- let output = try? FileHandle(forWritingTo: temporaryURL) else {
- throw APIError.transport("无法准备上传文件")
- }
- defer {
- try? output.close()
- try? FileManager.default.removeItem(at: temporaryURL)
- }
- for (key, value) in fields.sorted(by: { $0.key < $1.key }) {
- try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8))
- }
- try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\nContent-Type: \(mimeType)\r\n\r\n".utf8))
- let input = try FileHandle(forReadingFrom: fileURL)
- while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
- try output.write(contentsOf: chunk)
- }
- try input.close()
- try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8))
- try output.synchronize()
- var request = try makeRequest(path: path, method: .post, body: nil)
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
- let data: Data
- let response: URLResponse
- do {
- let progressDelegate = UploadProgressDelegate(progress: progress)
- (data, response) = try await session.upload(
- for: request,
- fromFile: temporaryURL,
- delegate: progressDelegate
- )
- } catch {
- throw APIError.transport(error.localizedDescription)
- }
- guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
- if http.statusCode == 401 { throw APIError.unauthorized }
- guard (200..<300).contains(http.statusCode) else {
- throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
- }
- let envelope: APIEnvelope<T>
- do {
- envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
- } catch {
- throw APIError.decoding(error.localizedDescription)
- }
- guard envelope.code == 0 else {
- throw APIError.server(code: envelope.code, message: envelope.message)
- }
- guard let result = envelope.data else { throw APIError.missingData }
- return result
- }
- private func performDownload(_ path: String, to destinationURL: URL) async throws {
- guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
- var request = try makeRequest(path: path, method: .get, body: nil)
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- let temporaryURL: URL
- let response: URLResponse
- do {
- (temporaryURL, response) = try await session.download(for: request)
- } catch {
- throw APIError.transport(error.localizedDescription)
- }
- guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
- if http.statusCode == 401 { throw APIError.unauthorized }
- guard (200..<300).contains(http.statusCode) else {
- throw APIError.server(code: http.statusCode, message: "云端文件下载失败")
- }
- let fileManager = FileManager.default
- try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
- if fileManager.fileExists(atPath: destinationURL.path) {
- return
- }
- do {
- try fileManager.moveItem(at: temporaryURL, to: destinationURL)
- } catch {
- throw APIError.transport("无法保存云端文件:\(error.localizedDescription)")
- }
- }
- private func makeRequest(path: String, method: HTTPMethod, body: Data?) throws -> URLRequest {
- let pathAndQuery = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
- let cleanPath = String(pathAndQuery[0]).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
- let pathURL = cleanPath.split(separator: "/").reduce(baseURL) { partial, component in
- partial.appendingPathComponent(String(component))
- }
- var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false)
- if pathAndQuery.count == 2 {
- components?.percentEncodedQuery = String(pathAndQuery[1])
- }
- guard let url = components?.url else { throw APIError.invalidConfiguration }
- var request = URLRequest(url: url)
- request.httpMethod = method.rawValue
- request.setValue("application/json", forHTTPHeaderField: "Accept")
- if let body {
- request.httpBody = body
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- }
- return request
- }
- static let decoder: JSONDecoder = {
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .custom { decoder in
- let container = try decoder.singleValueContainer()
- let value = try container.decode(String.self)
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- if let date = formatter.date(from: value) { return date }
- formatter.formatOptions = [.withInternetDateTime]
- if let date = formatter.date(from: value) { return date }
- throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date")
- }
- return decoder
- }()
- private static func isSecureConnectionError(_ code: URLError.Code) -> Bool {
- switch code {
- case .secureConnectionFailed,
- .serverCertificateHasBadDate,
- .serverCertificateUntrusted,
- .serverCertificateHasUnknownRoot,
- .serverCertificateNotYetValid,
- .clientCertificateRejected,
- .clientCertificateRequired,
- .appTransportSecurityRequiresSecureConnection:
- return true
- default:
- return false
- }
- }
- private static func serverError(from data: Data, fallbackStatusCode: Int) -> APIError {
- if fallbackStatusCode == 401 {
- return .unauthorized
- }
- if let envelope = try? decoder.decode(APIErrorEnvelope.self, from: data) {
- return .server(code: envelope.code, message: envelope.message)
- }
- return .server(code: fallbackStatusCode, message: "服务器请求失败(\(fallbackStatusCode))")
- }
- }
- private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
- private let progress: @Sendable (Double) -> Void
- init(progress: @escaping @Sendable (Double) -> Void) {
- self.progress = progress
- }
- func urlSession(
- _ session: URLSession,
- task: URLSessionTask,
- didSendBodyData bytesSent: Int64,
- totalBytesSent: Int64,
- totalBytesExpectedToSend: Int64
- ) {
- guard totalBytesExpectedToSend > 0 else { return }
- progress(min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1))
- }
- }
|