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, agentConversationID: nil, pendingAgentRequestID: nil, pendingAgentContent: nil, pendingAgentDestination: nil ) 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, agentContent: String? = nil, agentDestination: String = "HOLOLAKE_AGENT" ) async { await perform("正在与电脑同步…") { guard var current = session else { throw HoloLakeMobileError.missingSession } let requestedAgentContent = agentContent?.trimmingCharacters(in: .whitespacesAndNewlines) if let requestedAgentContent, !requestedAgentContent.isEmpty { if let pending = current.pendingAgentContent, pending != requestedAgentContent { throw HoloLakeMobileError.pendingAgentRequest } if let pending = current.pendingAgentDestination, pending != agentDestination { throw HoloLakeMobileError.pendingAgentRequest } if current.pendingAgentRequestID == nil { current.pendingAgentRequestID = UUID().uuidString.lowercased() current.pendingAgentContent = requestedAgentContent current.pendingAgentDestination = agentDestination } } 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 agentMessage: MobileAgentMessageInput? if let requestID = current.pendingAgentRequestID, let content = current.pendingAgentContent, requestedAgentContent != nil { agentMessage = MobileAgentMessageInput( destination: current.pendingAgentDestination ?? "HOLOLAKE_AGENT", codexThreadID: (current.pendingAgentDestination == "CODEX_SELECTED_THREAD") ? snapshot?.codexTarget?.threadID : nil, conversationID: (current.pendingAgentDestination == "CODEX_SELECTED_THREAD") ? nil : current.agentConversationID, content: content, requestID: requestID ) } else { agentMessage = 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, agentMessage: agentMessage ) ) 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 if let exchange = nextSnapshot.agentExchange, exchange.requestID == current.pendingAgentRequestID, exchange.state == "COMPLETED" { if current.pendingAgentDestination != "CODEX_SELECTED_THREAD" { current.agentConversationID = exchange.conversationID } current.pendingAgentRequestID = nil current.pendingAgentContent = nil current.pendingAgentDestination = nil } try HoloLakeKeychain.save(current) session = current snapshot = nextSnapshot stateText = "已同步 · \(nextSnapshot.desktopName)" } } func sendAgentMessage(_ content: String, destination: String) async { await sync(agentContent: content, agentDestination: destination) } 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 = 120 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) }) } }