import CryptoKit import Foundation import UIKit @MainActor final class HoloLakeSyncClient: ObservableObject { static let clientNumber = "HLP-IOS-CLIENT-001" static let pairAAD = Data("hololake.mobile.pair/v1".utf8) @Published private(set) var session: StoredSession? @Published private(set) var snapshot: MobileSyncSnapshot? @Published private(set) var stateText = "尚未配对" @Published private(set) var isWorking = false @Published var errorText: String? init() { do { session = try HoloLakeKeychain.load() stateText = session == nil ? "尚未配对" : "已配对,等待手动同步" } catch { errorText = error.localizedDescription } } func pair(with url: URL) async { await perform("正在校验配对…") { let descriptor = try PairingDescriptor(url: url) let requestID = UUID().uuidString.lowercased() let payload = PairRequest( deviceName: UIDevice.current.name, platform: "IOS", requestID: requestID ) let responseData = try await Self.exchange( url: descriptor.baseURL.appending(path: "v1/pair"), key: descriptor.secret, aad: Self.pairAAD, headers: ["X-HoloLake-Pairing-ID": descriptor.pairingID], payload: payload ) let response = try JSONDecoder().decode(PairResponse.self, from: responseData) guard response.schema == "hololake.mobile-sync/v1", response.state == "PAIRED", response.rootNodeRole == "USER_LOCAL_COMPUTER_TERMINAL", response.requestID == requestID, let sessionKey = Data(base64URL: response.sessionKey), sessionKey.count == 32 else { throw HoloLakeMobileError.invalidResponse } let next = StoredSession( host: descriptor.host, port: descriptor.port, deviceID: response.deviceID, sessionKey: sessionKey, desktopName: response.desktopName, counter: 0, cursor: 0 ) try HoloLakeKeychain.save(next) session = next stateText = "已与 \(response.desktopName) 配对" } } func pair(from text: String) async { guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else { errorText = HoloLakeMobileError.invalidPairingLink.localizedDescription return } await pair(with: url) } func sync(captureTitle: String? = nil, captureBody: String? = nil) async { await perform("正在与电脑同步…") { guard var current = session else { throw HoloLakeMobileError.missingSession } current.counter += 1 // Consume and persist the counter before transport. If the root node accepts a // request but its response is lost, the next manual retry still moves forward. try HoloLakeKeychain.save(current) session = current let capture: MobileCaptureInput? if let body = captureBody?.trimmingCharacters(in: .whitespacesAndNewlines), !body.isEmpty { capture = MobileCaptureInput( title: (captureTitle ?? "").trimmingCharacters(in: .whitespacesAndNewlines), body: body, requestID: UUID().uuidString.lowercased() ) } else { capture = nil } let aad = Data("hololake.mobile.sync/v1:\(current.deviceID)".utf8) let responseData = try await Self.exchange( url: current.baseURL.appending(path: "v1/sync"), key: current.sessionKey, aad: aad, headers: ["X-HoloLake-Device-ID": current.deviceID], payload: SyncRequest(counter: current.counter, afterCursor: nil, capture: capture) ) let nextSnapshot = try JSONDecoder().decode(MobileSyncSnapshot.self, from: responseData) guard nextSnapshot.schema == "hololake.mobile-sync/v1", nextSnapshot.state == "SYNCED_WITH_ROOT_NODE", nextSnapshot.rootNodeOnline, nextSnapshot.boundary.mobileRole == "REMOTE_BODY_ENTRY_OF_THE_SAME_PERSONA_SYSTEM", !nextSnapshot.boundary.remoteDesktopClone, !nextSnapshot.boundary.desktopOfflineExecution else { throw HoloLakeMobileError.invalidResponse } current.cursor = nextSnapshot.cursor try HoloLakeKeychain.save(current) session = current snapshot = nextSnapshot stateText = "已同步 · \(nextSnapshot.desktopName)" } } func disconnect() { do { try HoloLakeKeychain.remove() session = nil snapshot = nil stateText = "尚未配对" } catch { errorText = error.localizedDescription } } private func perform(_ activeText: String, operation: () async throws -> Void) async { guard !isWorking else { return } isWorking = true errorText = nil stateText = activeText do { try await operation() } catch { errorText = error.localizedDescription stateText = session == nil ? "尚未配对" : "同步未完成" } isWorking = false } static func exchange( url: URL, key: Data, aad: Data, headers: [String: String], payload: Payload, session: URLSession = .shared ) async throws -> Data { let clear = try JSONEncoder().encode(payload) let nonceData = randomNonce() let nonce = try ChaChaPoly.Nonce(data: nonceData) let sealed = try ChaChaPoly.seal( clear, using: SymmetricKey(data: key), nonce: nonce, authenticating: aad ) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 8 request.cachePolicy = .reloadIgnoringLocalCacheData request.httpBody = sealed.ciphertext + sealed.tag request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.setValue(nonceData.base64URL, forHTTPHeaderField: "X-HoloLake-Nonce") headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (body, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw HoloLakeMobileError.invalidResponse } guard http.statusCode == 200 else { let code = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["code"] as? String throw HoloLakeMobileError.rejected(code ?? "HTTP_\(http.statusCode)") } guard let nonceHeader = http.value(forHTTPHeaderField: "X-HoloLake-Nonce"), let responseNonceData = Data(base64URL: nonceHeader), responseNonceData.count == 12 else { throw HoloLakeMobileError.missingNonce } let responseNonce = try ChaChaPoly.Nonce(data: responseNonceData) guard body.count >= 16 else { throw HoloLakeMobileError.invalidResponse } let responseBox = try ChaChaPoly.SealedBox( nonce: responseNonce, ciphertext: body.dropLast(16), tag: body.suffix(16) ) return try ChaChaPoly.open(responseBox, using: SymmetricKey(data: key), authenticating: aad) } static func randomNonce() -> Data { Data((0..<12).map { _ in UInt8.random(in: .min ... .max) }) } }