import Foundation import Security struct PairingDescriptor: Equatable { let host: String let port: Int let pairingID: String let secret: Data init(url: URL) throws { guard url.scheme == "hololake", url.host == "pair", let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw HoloLakeMobileError.invalidPairingLink } var values: [String: String] = [:] for item in components.queryItems ?? [] { guard let value = item.value, values[item.name] == nil else { throw HoloLakeMobileError.invalidPairingLink } values[item.name] = value } guard let host = values["host"], !host.isEmpty, let portText = values["port"], let port = Int(portText), (1...65535).contains(port), let pairingID = values["id"], UUID(uuidString: pairingID) != nil, let secretText = values["secret"], let secret = Data(base64URL: secretText), secret.count == 32 else { throw HoloLakeMobileError.invalidPairingLink } self.host = host self.port = port self.pairingID = pairingID self.secret = secret } var baseURL: URL { URL(string: "http://\(host):\(port)")! } } struct StoredSession: Codable, Equatable { let host: String let port: Int let deviceID: String let sessionKey: Data let desktopName: String var counter: UInt64 var cursor: UInt64 var baseURL: URL { URL(string: "http://\(host):\(port)")! } } struct PairRequest: Codable { let deviceName: String let platform: String let requestID: String } struct PairResponse: Codable { let schema: String let state: String let deviceID: String let sessionKey: String let desktopName: String let rootNodeRole: String let requestID: String } struct SyncRequest: Codable { let counter: UInt64 let afterCursor: UInt64? let capture: MobileCaptureInput? } struct MobileCaptureInput: Codable { let title: String let body: String let requestID: String } struct MobileSyncSnapshot: Codable { let schema: String let state: String let cursor: UInt64 let generatedAtUnixMs: UInt64 let desktopName: String let rootNodeOnline: Bool let personalChannel: MobileChannelProjection let webNovel: MobileWebNovelProjection let education: MobileEducationProjection let recentCaptures: [MobileCaptureProjection] let boundary: MobileBoundaryProjection } struct MobileChannelProjection: Codable { let home: String let growthEventCount: UInt64 let integrity: String } struct MobileWebNovelProjection: Codable { let workCount: UInt64 let volumeCount: UInt64 let chapterCount: UInt64 let works: [MobileWebNovelWork] } struct MobileWebNovelWork: Codable, Identifiable { let workID: String let title: String let status: String let updatedAtUnixMs: UInt64 var id: String { workID } } struct MobileEducationProjection: Codable { let activeTableCount: UInt64 let archivedTableCount: UInt64 let unassignedTableCount: UInt64 let sensitiveValuesIncluded: Bool } struct MobileCaptureProjection: Codable, Identifiable { let cursor: UInt64 let captureID: String let title: String let body: String let sourceDeviceID: String let createdAtUnixMs: UInt64 var id: String { captureID } } struct MobileBoundaryProjection: Codable { let mobileRole: String let remoteDesktopClone: Bool let desktopOfflineExecution: Bool let sensitiveEducationValues: String let modelAPI: String } enum HoloLakeMobileError: LocalizedError { case invalidPairingLink case invalidResponse case rejected(String) case missingNonce case missingSession case keychain(OSStatus) var errorDescription: String? { switch self { case .invalidPairingLink: return "配对链接无效或已损坏。" case .invalidResponse: return "电脑端返回了无法校验的响应。" case .rejected(let code): return "电脑端拒绝了本次请求:\(code)" case .missingNonce: return "加密响应缺少一次性随机数。" case .missingSession: return "请先与电脑端 HoloLake 配对。" case .keychain(let status): return "本机钥匙串写入失败(\(status))。" } } } extension Data { init?(base64URL value: String) { var text = value.replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") text += String(repeating: "=", count: (4 - text.count % 4) % 4) guard let data = Data(base64Encoded: text) else { return nil } self = data } var base64URL: String { base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } }