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 "尚未检测"
@@ -29,6 +29,19 @@ final class FleetViewModel: ObservableObject {
var uptimeSeconds: Double?
var chainValues: DeviceLinkState?
var testing: Bool
/// Why the last probe ended where it did, for the detail text.
var probeError: UpstreamError?
/// When the last probe finished. The list shows how old a reading is, which in relay mode is
/// the difference between "online" meaning now and meaning forty seconds ago.
var checkedAt: Date?
/// Which path this node is reached by. The badge exists because without it "this one is
/// slow" has no answer that a user can act on.
var channel: InstanceChannel { instance.endpoint.channel }
var freshness: SnapshotFreshness {
testing ? .unknown : SnapshotFreshness.classify(age: checkedAt.map { Date().timeIntervalSince($0) })
}
var storageUsageRatio: Double? {
guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else {
@@ -143,7 +156,7 @@ final class FleetViewModel: ObservableObject {
expectedRevision: original.instance.configRevision,
InstancePatch(
name: input.name,
origin: input.origin,
endpoint: input.endpoint,
tags: input.tags,
password: input.password
)
@@ -215,6 +228,8 @@ final class FleetViewModel: ObservableObject {
) {
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
rows[index].status = result?.status ?? .unknown
rows[index].probeError = result?.error
rows[index].checkedAt = result?.checkedAt
if let summary {
rows[index].deviceName = summary.deviceName
rows[index].phoneNumber = summary.phoneNumber
@@ -260,7 +275,7 @@ final class FleetViewModel: ObservableObject {
guard !searchText.isEmpty else { return true }
let haystacks: [String] = [
row.instance.name,
row.instance.origin.absoluteString,
row.instance.endpoint.displayAddress,
row.instance.tags.joined(separator: " "),
row.operatorName ?? "",
row.phoneNumber ?? "",
@@ -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
)
@@ -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))
}
}
}
}
@@ -0,0 +1,106 @@
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。"
}
}
}