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:
chick
2026-09-16 18:57:41 +08:00
parent a6867bc9cb
commit 3f6090081f
37 changed files with 4129 additions and 199 deletions
@@ -5,6 +5,8 @@ struct FleetView: View {
@State private var showsDeleteDialog = false
@State private var editingInstance: Instance?
@State private var isSummaryCollapsed = true
@State private var showingPairing = false
@StateObject private var pairingModel: RelayPairingViewModel
init(model: FleetViewModel? = nil) {
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
// 所以调试开关必须先落到这个具体的 model 对象。
@@ -32,6 +34,11 @@ struct FleetView: View {
}
#endif
_model = StateObject(wrappedValue: target)
// 配对视图模型与列表共用同一个仓库,否则配对成功后列表看不到新节点。
_pairingModel = StateObject(wrappedValue: RelayPairingViewModel(
repository: DefaultContainer.makeInstanceRepository(),
identities: DefaultContainer.deviceIdentities
))
}
var body: some View {
@@ -134,6 +141,11 @@ struct FleetView: View {
}
.disabled(model.visibleRows.isEmpty)
AIIconButton("配对中继节点", systemImage: "link.badge.plus") {
showingPairing = true
}
.disabled(model.isLoading)
AIIconButton("添加实例", systemImage: "plus") {
model.showingForm = true
}
@@ -150,6 +162,9 @@ struct FleetView: View {
instance: editingInstance
)
}
.sheet(isPresented: $showingPairing) {
RelayPairingView(model: pairingModel, fleet: model)
}
.onChange(of: model.showingForm) { showing in
if !showing {
editingInstance = nil
@@ -839,11 +854,20 @@ private extension FleetView {
tint: statusColor(row.status)
)
}
Text(row.instance.origin.absoluteString)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: 6) {
Text(row.instance.endpoint.displayAddress)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
// 通道徽标:没有它,"这台为什么慢"无法回答。
channelBadge(row.channel)
if !row.testing, row.freshness != .fresh {
Text(row.freshness.label)
.font(.caption2)
.foregroundStyle(AITheme.muted)
}
}
}
// 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。
@@ -1039,16 +1063,35 @@ private extension FleetView {
}
/// 只解释"为什么不是正常状态"。在线节点的细节交给指标栅格,上游原词(online 之类)不外泄。
/// 通道徽标是刻意低调的:它解释延迟来源,不是需要用户处理的状态。
private func channelBadge(_ channel: InstanceChannel) -> some View {
Text(channel.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(AITheme.canvas, in: Capsule())
.overlay(Capsule().stroke(AITheme.line, lineWidth: 1))
.accessibilityLabel(channel == .relay ? "经由中继" : "内网直通")
}
private func statusLine(_ row: FleetViewModel.Row) -> String? {
if row.testing { return "正在读取运行信息" }
switch row.status {
case .online:
return nil
case .degraded:
// 中继模式下"部分接口不可用"可能有具体得多的原因,优先给能照做的说法。
if let error = row.probeError {
return ErrorMessage.describe(error, nodeName: row.instance.name)
}
return "部分接口不可用,可在详情查看失败接口"
case .authRequired:
return "节点开启了访问密码,进入详情登录"
case .offline:
if let error = row.probeError {
return ErrorMessage.describe(error, nodeName: row.instance.name)
}
return "节点不可达,最后结果已过期"
case .unknown:
return "尚未检测"