feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Pairs this phone with a node over the LAN.
|
||||
///
|
||||
/// The flow needs the phone to be inside the network, which is why it is a separate sheet rather
|
||||
/// than part of the instance form: the address here is the node's LAN address, not the relay's, and
|
||||
/// the password is the node's own — the one fact the pairing flow leans on.
|
||||
struct RelayPairingView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: RelayPairingViewModel
|
||||
@ObservedObject var fleet: FleetViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
field("节点内网地址", text: $model.host, placeholder: "192.168.1.10")
|
||||
field("节点访问密码", text: $model.password, placeholder: "SimAdmin 密码", secure: true)
|
||||
field("显示名称", text: $model.name, placeholder: "可留空,默认使用节点 ID")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("标签")
|
||||
TagEditorView(tags: $model.tags, suggestions: fleet.allTags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AIStatusBanner(
|
||||
"配对需要手机与节点在同一内网。节点上先运行一次 msa-agent --pair,配对成功后它的管理端口会自动关闭。",
|
||||
tint: AITheme.muted,
|
||||
systemImage: "wifi"
|
||||
)
|
||||
|
||||
if let message = model.errorMessage {
|
||||
AIStatusBanner(message, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if case .done(let nodeID) = model.stage {
|
||||
AIStatusBanner(
|
||||
"配对完成,节点 \(nodeID) 已加入列表。",
|
||||
tint: AITheme.success,
|
||||
systemImage: "checkmark.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("配对中继节点")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button(model.isDone ? "完成" : "取消") {
|
||||
if model.isDone {
|
||||
Task { await fleet.refresh() }
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
|
||||
if !model.isDone {
|
||||
Button {
|
||||
Task { await model.pair() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if model.stage == .pairing {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Text(model.stage == .pairing ? "配对中" : "开始配对")
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!model.canSubmit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ label: String,
|
||||
text: Binding<String>,
|
||||
placeholder: String,
|
||||
secure: Bool = false
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel(label)
|
||||
if secure {
|
||||
SecureField(placeholder, text: text)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
} else {
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.body)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user