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:
@@ -16,13 +16,18 @@ struct InstanceFormView: View {
|
||||
self.model = model
|
||||
self.instance = instance
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_originText = State(initialValue: instance?.endpoint.displayAddress ?? "")
|
||||
_tags = State(initialValue: instance?.tags ?? [])
|
||||
}
|
||||
|
||||
/// A relay instance is edited by name and tags only. Its address is not a URL the user typed —
|
||||
/// it is a pairing, and letting the form rewrite it would either drop the node's keys or produce
|
||||
/// an endpoint that fails validation. Re-pairing is the way to change it.
|
||||
private var isRelay: Bool { instance?.endpoint.isRelay == true }
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (isRelay || !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -32,16 +37,29 @@ struct InstanceFormView: View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
if isRelay {
|
||||
relayChannelRow
|
||||
} else {
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
}
|
||||
tagEditor
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
if !isRelay {
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if isRelay {
|
||||
AIStatusBanner(
|
||||
"中继节点的密码保存在节点 agent 上,手机不持有;更换中继需要重新配对。",
|
||||
tint: AITheme.muted,
|
||||
systemImage: "lock.shield"
|
||||
)
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
@@ -81,6 +99,28 @@ struct InstanceFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay channel is read-only here, but it must still be visible: without it the form shows
|
||||
/// a node with no address at all, which reads as missing data rather than as a different mode.
|
||||
private var relayChannelRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("通道")
|
||||
HStack(spacing: 8) {
|
||||
Text("中继")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(AITheme.canvas, in: Capsule())
|
||||
Text(originText.isEmpty ? "已配对节点" : originText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var tagEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("标签")
|
||||
@@ -128,9 +168,12 @@ struct InstanceFormView: View {
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
// An existing relay instance keeps its endpoint: the address field only edits a direct
|
||||
// origin, and rewriting a pairing into a direct one here would silently drop the node's keys.
|
||||
let endpoint = instance?.endpoint.isRelay == true ? instance!.endpoint : InstanceEndpoint.direct(origin)
|
||||
let input = InstanceInput(
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
endpoint: endpoint,
|
||||
tags: tags,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user