feat: admit numbered iPhone companion

This commit is contained in:
冰朔 2026-08-19 07:28:26 +08:00
commit fe1aaade0e
20 changed files with 1563 additions and 7 deletions

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}
}

View file

@ -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) }
}
}
}
}

View file

@ -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: "")
}
}

View file

@ -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) })
}
}