- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
194 lines
6.5 KiB
Swift
194 lines
6.5 KiB
Swift
import SwiftUI
|
|
|
|
struct InstanceFormView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@ObservedObject var model: FleetViewModel
|
|
let instance: Instance?
|
|
@State private var name = ""
|
|
@State private var originText = ""
|
|
@State private var tags: [String] = []
|
|
@State private var tagDraft = ""
|
|
@State private var password = ""
|
|
@State private var validationMessage: String?
|
|
@State private var isSaving = false
|
|
|
|
init(model: FleetViewModel, instance: Instance? = nil) {
|
|
self.model = model
|
|
self.instance = instance
|
|
_name = State(initialValue: instance?.name ?? "")
|
|
_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
|
|
&& (isRelay || !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
field("名称", text: $name, placeholder: "Home Node")
|
|
if isRelay {
|
|
relayChannelRow
|
|
} else {
|
|
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
|
}
|
|
tagEditor
|
|
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")
|
|
}
|
|
}
|
|
.padding(16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.aiBottomActionBar {
|
|
AIActionRow {
|
|
Button("取消") { dismiss() }
|
|
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
|
|
|
Button {
|
|
save()
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
if isSaving {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
Text(isSaving ? "保存中" : "保存实例")
|
|
}
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
|
.disabled(!canSave || isSaving)
|
|
}
|
|
}
|
|
.background(AITheme.canvas.ignoresSafeArea())
|
|
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
|
|
#if os(iOS)
|
|
// 表单表统一用行内标题:大标题会在卡片上方留出一整条空白,
|
|
// 而「批量标签」等其它表已经是行内样式。
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 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("标签")
|
|
TagEditorView(tags: $tags, suggestions: model.allTags)
|
|
}
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
|
|
private func field(
|
|
_ title: String,
|
|
text: Binding<String>,
|
|
placeholder: String,
|
|
secure: Bool = false
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
AIFieldLabel(title)
|
|
if secure {
|
|
SecureField(placeholder, text: text)
|
|
.textFieldStyle(AIFieldStyle())
|
|
.textContentType(.password)
|
|
.accessibilityLabel(title)
|
|
} else {
|
|
TextField(placeholder, text: text)
|
|
.textFieldStyle(AIFieldStyle())
|
|
.accessibilityLabel(title)
|
|
.submitLabel(.done)
|
|
#if os(iOS)
|
|
.keyboardType(title == "地址" ? .URL : .default)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func save() {
|
|
guard !isSaving else { return }
|
|
isSaving = true
|
|
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
|
origin.scheme == "http" || origin.scheme == "https",
|
|
origin.host != nil
|
|
else {
|
|
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
|
|
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,
|
|
endpoint: endpoint,
|
|
tags: tags,
|
|
password: password.isEmpty ? nil : password
|
|
)
|
|
Task {
|
|
do {
|
|
if let instance {
|
|
try await model.update(instance.id, input)
|
|
} else {
|
|
try await model.add(input)
|
|
}
|
|
} catch {
|
|
validationMessage = "保存失败,请检查地址是否重复。"
|
|
}
|
|
isSaving = false
|
|
}
|
|
}
|
|
}
|