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:
@@ -1,18 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceService: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
private let transport: any NodeTransport
|
||||
|
||||
public init(transport: any NodeTransport) {
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
/// Direct-mode construction, kept for the existing call sites and tests.
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
|
||||
}
|
||||
|
||||
public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot {
|
||||
@@ -76,30 +77,7 @@ public struct DeviceService: Sendable {
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw DeviceServiceError.unauthorized
|
||||
}
|
||||
let sessionService = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessionService.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessionService.cookie(for: instance)
|
||||
else {
|
||||
throw DeviceServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
|
||||
}
|
||||
|
||||
static func dataObject(_ data: Data) throws -> [String: Any] {
|
||||
|
||||
@@ -22,8 +22,10 @@ final class DeviceViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
do {
|
||||
snapshot = try await service.snapshot(instance)
|
||||
} catch DeviceServiceError.unauthorized {
|
||||
} catch DeviceServiceError.unauthorized, UpstreamError.unauthorized {
|
||||
errorMessage = "需要实例密码登录后才能读取设备数据。"
|
||||
} catch let error as UpstreamError {
|
||||
errorMessage = ErrorMessage.describe(error, nodeName: instance.name)
|
||||
} catch {
|
||||
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
|
||||
}
|
||||
|
||||
@@ -93,9 +93,9 @@ struct InstanceDetailView: View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
|
||||
AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true)
|
||||
AIKeyValueRow("地址", value: model.instance.endpoint.displayAddress, monospaced: true)
|
||||
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
|
||||
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置")
|
||||
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? model.instance.endpoint.credentialDescription : "未设置")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ struct InstanceDetailView: View {
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
Text(model.instance.endpoint.displayAddress)
|
||||
.font(.footnote.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(AITheme.ink)
|
||||
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,25 @@ public struct LogCenterService: Sendable {
|
||||
var diagnostic: InstanceDiagnostic?
|
||||
}
|
||||
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
private let transport: any NodeTransport
|
||||
private let store: any LogStore
|
||||
|
||||
public init(transport: any NodeTransport, store: any LogStore) {
|
||||
self.transport = transport
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// Direct-mode construction, kept for the existing call sites and tests.
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil,
|
||||
store: any LogStore
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
self.store = store
|
||||
self.init(
|
||||
transport: DirectTransport(client: client, sessions: sessions, credentials: credentials),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
|
||||
public func refresh(_ instances: [Instance]) async throws -> LogSnapshot {
|
||||
@@ -115,7 +119,11 @@ public struct LogCenterService: Sendable {
|
||||
private func probe(_ instance: Instance) async -> ProbeOutcome {
|
||||
let startedAt = Date()
|
||||
do {
|
||||
let response = try await client.send(.health, baseURL: instance.origin, cookie: nil)
|
||||
let response = try await transport.send(
|
||||
.health,
|
||||
instanceID: instance.id,
|
||||
endpoint: instance.endpoint
|
||||
)
|
||||
let duration = elapsed(startedAt)
|
||||
let root = (try? JSONSerialization.jsonObject(with: response.data)) as? [String: Any] ?? [:]
|
||||
return ProbeOutcome(
|
||||
@@ -207,7 +215,7 @@ public struct LogCenterService: Sendable {
|
||||
return InstanceDiagnostic(
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
origin: instance.origin.absoluteString,
|
||||
origin: instance.endpoint.displayAddress,
|
||||
tags: instance.tags,
|
||||
state: probe.status,
|
||||
healthStatus: Self.healthLabel(probe),
|
||||
@@ -226,30 +234,7 @@ public struct LogCenterService: Sendable {
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
let service = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await service.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
|
||||
}
|
||||
|
||||
private func elapsed(_ startedAt: Date) -> Int {
|
||||
|
||||
@@ -7,18 +7,19 @@ public enum MessageServiceError: Error, Equatable {
|
||||
}
|
||||
|
||||
public struct MessageService: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
private let transport: any NodeTransport
|
||||
|
||||
public init(transport: any NodeTransport) {
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
/// Direct-mode construction, kept for the existing call sites and tests.
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
|
||||
}
|
||||
|
||||
public func messages(
|
||||
@@ -75,30 +76,7 @@ public struct MessageService: Sendable {
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw MessageServiceError.unauthorized
|
||||
}
|
||||
let sessions = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessions.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessions.cookie(for: instance)
|
||||
else {
|
||||
throw MessageServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
|
||||
}
|
||||
|
||||
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
|
||||
|
||||
@@ -60,8 +60,12 @@ final class MessagesViewModel: ObservableObject {
|
||||
}
|
||||
let page = try await service.messages(loaded, limit: 100)
|
||||
rows = page.messages
|
||||
} catch MessageServiceError.unauthorized {
|
||||
} catch MessageServiceError.unauthorized, UpstreamError.unauthorized {
|
||||
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
|
||||
} catch let error as UpstreamError {
|
||||
// 中继模式下失败原因有四类以上,各自对应不同处理动作;
|
||||
// 统一兜底文案会把"重试即可"和"需要重新配对"说成同一件事。
|
||||
errorMessage = ErrorMessage.describe(error, nodeName: selectedInstance?.name ?? "节点")
|
||||
} catch {
|
||||
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user