hololake-system-architecture/product-source/hololake-native-desktop/mobile/ios/Sources/HoloLakeSyncClient.swift

190 lines
7.8 KiB
Swift
Raw Normal View History

2026-08-19 07:28:26 +08:00
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<Payload: Encodable>(
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) })
}
}