- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
107 lines
4.3 KiB
Swift
107 lines
4.3 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Drives the pairing sheet: collect the node's LAN address and password, run the pairing exchange,
|
|
/// and hand the result to the repository as a new instance.
|
|
///
|
|
/// The pairing window on the node closes after one success, so a failure here is not merely "try
|
|
/// again later": it may mean the window is shut and the operator has to re-open it on the node. The
|
|
/// error text says which of the two happened, because the two call for different actions.
|
|
@MainActor
|
|
final class RelayPairingViewModel: ObservableObject {
|
|
enum Stage: Equatable {
|
|
case idle
|
|
case pairing
|
|
case done(nodeID: String)
|
|
}
|
|
|
|
@Published var host = ""
|
|
@Published var password = ""
|
|
@Published var name = ""
|
|
@Published var tags: [String] = []
|
|
@Published var stage: Stage = .idle
|
|
@Published var errorMessage: String?
|
|
|
|
private let pairing: RelayPairing
|
|
private let identities: FileDeviceIdentityStore
|
|
private let repository: any InstanceRepository
|
|
|
|
init(
|
|
repository: any InstanceRepository,
|
|
identities: FileDeviceIdentityStore,
|
|
pairing: RelayPairing = RelayPairing()
|
|
) {
|
|
self.repository = repository
|
|
self.identities = identities
|
|
self.pairing = pairing
|
|
}
|
|
|
|
var canSubmit: Bool {
|
|
!host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
&& !password.isEmpty
|
|
&& stage != .pairing
|
|
}
|
|
|
|
var isDone: Bool {
|
|
if case .done = stage { return true }
|
|
return false
|
|
}
|
|
|
|
func pair() async {
|
|
guard canSubmit else { return }
|
|
errorMessage = nil
|
|
stage = .pairing
|
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let displayName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
do {
|
|
// The identity is created on first use: the phone needs keys before it can pair, but it
|
|
// cannot know its device id until the node assigns one.
|
|
let identity = try identities.loadOrCreate()
|
|
let result = try await pairing.pair(
|
|
host: trimmedHost,
|
|
password: password,
|
|
deviceName: displayName.isEmpty ? trimmedHost : displayName,
|
|
identity: identity
|
|
)
|
|
// The id the node assigned has to be recorded before any request is sent, because it is
|
|
// signed into every envelope and the node verifies it against its own device list.
|
|
_ = try identities.assignDeviceID(result.deviceID)
|
|
let instance = try await repository.create(InstanceInput(
|
|
name: displayName.isEmpty ? (result.endpoint.nodeName ?? result.endpoint.nodeID) : displayName,
|
|
endpoint: .relay(result.endpoint),
|
|
tags: tags
|
|
))
|
|
_ = instance
|
|
stage = .done(nodeID: result.endpoint.nodeID)
|
|
password = ""
|
|
} catch let failure as RelayPairing.Failure {
|
|
stage = .idle
|
|
errorMessage = Self.describe(failure, host: trimmedHost)
|
|
} catch let error as RepositoryError {
|
|
stage = .idle
|
|
errorMessage = error == .duplicateOrigin
|
|
? "该节点已经在中继列表中,无需重复配对。"
|
|
: "配对成功但保存失败,请重试。"
|
|
} catch {
|
|
stage = .idle
|
|
errorMessage = "配对失败:\(error)"
|
|
}
|
|
}
|
|
|
|
/// The pairing failure vocabulary is small and each case has one obvious next step, so the text
|
|
/// states the step rather than the condition.
|
|
static func describe(_ failure: RelayPairing.Failure, host: String) -> String {
|
|
switch failure {
|
|
case .rejected:
|
|
return "节点密码不正确。请确认使用的是该节点 SimAdmin 的访问密码。"
|
|
case .windowClosed:
|
|
return "节点上的配对窗口已关闭。请在节点上重新运行一次配对(msa-agent --pair)后重试。"
|
|
case .unreachable:
|
|
return "无法连接 \(host):3001。请确认手机与节点在同一内网,且节点上已启动配对。"
|
|
case .malformedResponse:
|
|
return "节点返回的内容不是有效的配对响应。请确认节点上运行的是本方案的 agent。"
|
|
}
|
|
}
|
|
}
|