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,203 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var client: HoloLakeSyncClient
|
||||
@State private var pairingText = ""
|
||||
@State private var captureTitle = ""
|
||||
@State private var captureBody = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [HoloLakeDesign.deep, HoloLakeDesign.lake, HoloLakeDesign.violet.opacity(0.78)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
header
|
||||
if client.session == nil { pairingCard } else { dashboard }
|
||||
boundaryCard
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.alert("HoloLake", isPresented: Binding(
|
||||
get: { client.errorText != nil },
|
||||
set: { if !$0 { client.errorText = nil } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) { client.errorText = nil }
|
||||
} message: {
|
||||
Text(client.errorText ?? "")
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("HoloLake").font(.title.weight(.semibold)).tracking(2)
|
||||
Text("同一频道 · iPhone 轻入口")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(HoloLakeDesign.muted)
|
||||
}
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(client.session == nil ? HoloLakeDesign.gold : HoloLakeDesign.mint)
|
||||
.frame(width: 11, height: 11)
|
||||
.shadow(color: client.session == nil ? HoloLakeDesign.gold : HoloLakeDesign.mint, radius: 8)
|
||||
}
|
||||
.foregroundStyle(HoloLakeDesign.pearl)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private var pairingCard: some View {
|
||||
LakeCard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("连接电脑端").font(.title3.weight(.semibold))
|
||||
Text("先在电脑端打开“多端同步”,再扫描配对码或粘贴配对链接。配对密钥只保存在这台 iPhone 的钥匙串。")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(HoloLakeDesign.muted)
|
||||
|
||||
TextField("hololake://pair?…", text: $pairingText, axis: .vertical)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.padding(14)
|
||||
.background(.black.opacity(0.22), in: RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
HStack {
|
||||
Button("从剪贴板读取") {
|
||||
pairingText = UIPasteboard.general.string ?? ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("校验并配对") {
|
||||
Task { await client.pair(from: pairingText) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(HoloLakeDesign.gold)
|
||||
.foregroundStyle(HoloLakeDesign.deep)
|
||||
.disabled(pairingText.isEmpty || client.isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(HoloLakeDesign.pearl)
|
||||
}
|
||||
|
||||
private var dashboard: some View {
|
||||
VStack(spacing: 16) {
|
||||
LakeCard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(client.session?.desktopName ?? "电脑端")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(client.stateText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(HoloLakeDesign.muted)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await client.sync() }
|
||||
} label: {
|
||||
if client.isWorking {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("同步", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(HoloLakeDesign.mint)
|
||||
.foregroundStyle(HoloLakeDesign.deep)
|
||||
.disabled(client.isWorking)
|
||||
}
|
||||
|
||||
if let snapshot = client.snapshot {
|
||||
HStack(spacing: 10) {
|
||||
MetricTile(label: "作品", value: "\(snapshot.webNovel.workCount)")
|
||||
MetricTile(label: "章节", value: "\(snapshot.webNovel.chapterCount)")
|
||||
MetricTile(label: "教育表", value: "\(snapshot.education.activeTableCount)")
|
||||
}
|
||||
} else {
|
||||
Text("保持静止;只有你点击同步时才会连接电脑。")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(HoloLakeDesign.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LakeCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("随手记回频道").font(.headline)
|
||||
TextField("标题(可选)", text: $captureTitle)
|
||||
.padding(12)
|
||||
.background(.black.opacity(0.2), in: RoundedRectangle(cornerRadius: 14))
|
||||
TextField("正文", text: $captureBody, axis: .vertical)
|
||||
.lineLimit(4...10)
|
||||
.padding(12)
|
||||
.background(.black.opacity(0.2), in: RoundedRectangle(cornerRadius: 14))
|
||||
Button("写入电脑端收件箱") {
|
||||
let title = captureTitle
|
||||
let body = captureBody
|
||||
Task {
|
||||
await client.sync(captureTitle: title, captureBody: body)
|
||||
if client.errorText == nil {
|
||||
captureTitle = ""
|
||||
captureBody = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(captureBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || client.isWorking)
|
||||
}
|
||||
}
|
||||
|
||||
if let works = client.snapshot?.webNovel.works, !works.isEmpty {
|
||||
LakeCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("最近作品").font(.headline)
|
||||
ForEach(works.prefix(5)) { work in
|
||||
HStack {
|
||||
Text(work.title).lineLimit(1)
|
||||
Spacer()
|
||||
Text(work.status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(HoloLakeDesign.muted)
|
||||
}
|
||||
if work.id != works.prefix(5).last?.id {
|
||||
Divider().overlay(.white.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("解除本机配对", role: .destructive) {
|
||||
client.disconnect()
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
.foregroundStyle(HoloLakeDesign.pearl)
|
||||
}
|
||||
|
||||
private var boundaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("HLP-IOS-CLIENT-001")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(HoloLakeDesign.gold)
|
||||
Text("手机是同一频道的远程身体入口,不复制电脑桌面,不在电脑离线时执行人格或行业任务。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(HoloLakeDesign.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import SwiftUI
|
||||
|
||||
enum HoloLakeDesign {
|
||||
static let deep = Color(red: 0.025, green: 0.045, blue: 0.11)
|
||||
static let lake = Color(red: 0.08, green: 0.14, blue: 0.32)
|
||||
static let violet = Color(red: 0.34, green: 0.19, blue: 0.52)
|
||||
static let pearl = Color(red: 0.95, green: 0.96, blue: 1)
|
||||
static let muted = Color(red: 0.62, green: 0.66, blue: 0.78)
|
||||
static let gold = Color(red: 0.96, green: 0.87, blue: 0.62)
|
||||
static let mint = Color(red: 0.43, green: 0.93, blue: 0.78)
|
||||
}
|
||||
|
||||
struct LakeCard<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white.opacity(0.075), in: RoundedRectangle(cornerRadius: 24))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(.white.opacity(0.14), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricTile: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(label).font(.caption).foregroundStyle(HoloLakeDesign.muted)
|
||||
Text(value).font(.title2.weight(.semibold)).foregroundStyle(HoloLakeDesign.pearl)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
enum HoloLakeKeychain {
|
||||
private static let service = "com.guanghulab.hololake.mobile-sync"
|
||||
private static let account = "HLP-IOS-CLIENT-001"
|
||||
|
||||
static func load() throws -> StoredSession? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw HoloLakeMobileError.keychain(status)
|
||||
}
|
||||
return try JSONDecoder().decode(StoredSession.self, from: data)
|
||||
}
|
||||
|
||||
static func save(_ session: StoredSession) throws {
|
||||
let data = try JSONEncoder().encode(session)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
let update = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if update == errSecItemNotFound {
|
||||
var insertion = query
|
||||
insertion.merge(attributes) { _, new in new }
|
||||
let status = SecItemAdd(insertion as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw HoloLakeMobileError.keychain(status) }
|
||||
} else if update != errSecSuccess {
|
||||
throw HoloLakeMobileError.keychain(update)
|
||||
}
|
||||
}
|
||||
|
||||
static func remove() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw HoloLakeMobileError.keychain(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HoloLakeMobileApp: App {
|
||||
@StateObject private var client = HoloLakeSyncClient()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(client)
|
||||
.onOpenURL { url in
|
||||
Task { await client.pair(with: url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
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: "")
|
||||
}
|
||||
}
|
||||
|
|
@ -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