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。" } } }