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
@@ -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 = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
}