feat: admit numbered iPhone companion
This commit is contained in:
parent
fb8607f248
commit
fe1aaade0e
20 changed files with 1563 additions and 7 deletions
|
|
@ -0,0 +1,190 @@
|
|||
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) })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue