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:
@@ -28,29 +28,54 @@ public struct ConnectionResult: Equatable, Sendable {
|
||||
public let status: ConnectionStatus
|
||||
public let requestLatencyMilliseconds: Int?
|
||||
public let checkedAt: Date
|
||||
/// Why the probe ended where it did.
|
||||
///
|
||||
/// Kept alongside the status rather than folded into it because the status vocabulary is five
|
||||
/// values and the failure vocabulary is much larger: in relay mode "the relay is unreachable",
|
||||
/// "the agent is offline", "the envelope was refused" and "the node returned an error" are four
|
||||
/// different user actions, and a single `.offline` cannot say which one happened.
|
||||
public let error: UpstreamError?
|
||||
|
||||
public init(
|
||||
status: ConnectionStatus,
|
||||
requestLatencyMilliseconds: Int? = nil,
|
||||
checkedAt: Date
|
||||
checkedAt: Date,
|
||||
error: UpstreamError? = nil
|
||||
) {
|
||||
self.status = status
|
||||
self.requestLatencyMilliseconds = requestLatencyMilliseconds
|
||||
self.checkedAt = checkedAt
|
||||
self.error = error
|
||||
}
|
||||
|
||||
/// How old this reading is, for the freshness vocabulary. A relay reading is inherently older
|
||||
/// than a direct one, and saying so is the difference between "online" meaning now and meaning
|
||||
/// forty seconds ago.
|
||||
public var freshness: SnapshotFreshness {
|
||||
SnapshotFreshness.classify(age: Date().timeIntervalSince(checkedAt))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionProbe: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
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) {
|
||||
self.client = client
|
||||
self.init(transport: DirectTransport(client: client, sessions: InstanceSessionStore()))
|
||||
}
|
||||
|
||||
public func test(_ instance: Instance) async throws -> ConnectionResult {
|
||||
let startedAt = DispatchTime.now()
|
||||
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 latency = Self.elapsedMilliseconds(since: startedAt)
|
||||
if response.status == 401 || response.status == 403 {
|
||||
return ConnectionResult(
|
||||
@@ -66,26 +91,42 @@ public struct ConnectionProbe: Sendable {
|
||||
)
|
||||
} catch {
|
||||
let latency = Self.elapsedMilliseconds(since: startedAt)
|
||||
switch error {
|
||||
case UpstreamError.unauthorized:
|
||||
return ConnectionResult(
|
||||
status: .authRequired,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
case UpstreamError.timeout, is URLError:
|
||||
return ConnectionResult(
|
||||
status: .offline,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
default:
|
||||
return ConnectionResult(
|
||||
status: .degraded,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
}
|
||||
let upstream = error as? UpstreamError
|
||||
return ConnectionResult(
|
||||
status: Self.status(for: upstream, error: error),
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date(),
|
||||
error: upstream
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a failure onto the five-value status vocabulary the list already uses, while the typed
|
||||
/// error travels alongside it for the detail view.
|
||||
///
|
||||
/// The relay-mode cases are mapped rather than collapsed: an offline agent is a node problem the
|
||||
/// user waits out, a rejected envelope is a pairing problem that will never fix itself, and an
|
||||
/// unreachable relay is neither.
|
||||
static func status(for upstream: UpstreamError?, error: Error) -> ConnectionStatus {
|
||||
switch upstream {
|
||||
case .unauthorized:
|
||||
return .authRequired
|
||||
case .timeout, .relayTimeout, .relayUnreachable, .agentOffline, .agentTimeout, .nodeBusy:
|
||||
return .offline
|
||||
case .relayRejectedDevice, .relayNodeUnknown:
|
||||
// The pairing is gone: retrying cannot help, and the user has to pair again.
|
||||
return .offline
|
||||
case .relayPolicyDenied, .confirmationRejected, .envelopeRejected,
|
||||
.envelopeExpired, .relayResponseUnverifiable, .relayResponseMismatch,
|
||||
.relayBadEnvelope, .nodeAuthFailed, .payloadTooLarge:
|
||||
return .degraded
|
||||
case .relayRateLimited, .nodeRateLimited:
|
||||
return .degraded
|
||||
case .invalidRequest, .notSupported, .transportFailed, .invalidResponse, .forbidden:
|
||||
return .degraded
|
||||
case .none:
|
||||
if error is URLError { return .offline }
|
||||
return .degraded
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,49 @@ enum DefaultContainer {
|
||||
location: documentFileURL("log-center.json")
|
||||
)
|
||||
|
||||
/// The phone's relay identity, shared by every relay transport.
|
||||
///
|
||||
/// It is loaded once rather than per transport: the key pair must be the same one the node
|
||||
/// paired with, and a second load that regenerated it would silently invalidate every pairing.
|
||||
static let deviceIdentities = FileDeviceIdentityStore(
|
||||
location: documentFileURL("device-identity.json")
|
||||
)
|
||||
|
||||
static func documentFileURL(_ name: String) -> URL {
|
||||
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return directory.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
static func makeInstanceRepository() -> FileInstanceRepository {
|
||||
FileInstanceRepository(location: documentFileURL("instances.json"))
|
||||
}
|
||||
|
||||
/// The transport pair every feature service uses: direct on the LAN, relay through the agent.
|
||||
///
|
||||
/// The relay side reads the identity file per request rather than holding a copy: pairing can
|
||||
/// happen after a transport is built, and a held copy would sign with keys the node has never
|
||||
/// seen, which fails as an unreadable request with no useful cause.
|
||||
private static func makeTransport(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore
|
||||
) -> NodeTransportRouter {
|
||||
let identities = deviceIdentities
|
||||
return NodeTransportRouter(
|
||||
direct: DirectTransport(client: client, sessions: sessions, credentials: credentials),
|
||||
relay: RelayTransport(identity: { try identities.loadOrCreate() })
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeFleetModel() -> FleetViewModel {
|
||||
purgeLegacyGrouping()
|
||||
let sessions = InstanceSessionStore()
|
||||
return FleetViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
probe: ConnectionProbe(client: SimAdminClient()),
|
||||
repository: makeInstanceRepository(),
|
||||
probe: ConnectionProbe(transport: makeTransport(client: SimAdminClient(), sessions: sessions)),
|
||||
credentials: credentials,
|
||||
deviceService: DeviceService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -37,12 +64,11 @@ enum DefaultContainer {
|
||||
|
||||
@MainActor
|
||||
static func makeMessagesModel() -> MessagesViewModel {
|
||||
MessagesViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
let sessions = InstanceSessionStore()
|
||||
return MessagesViewModel(
|
||||
repository: makeInstanceRepository(),
|
||||
service: MessageService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -50,12 +76,11 @@ enum DefaultContainer {
|
||||
@MainActor
|
||||
static func makeLogsModel(autoRefreshOnAppear: Bool = true) -> LogsViewModel {
|
||||
let store = logStore
|
||||
let sessions = InstanceSessionStore()
|
||||
return LogsViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
repository: makeInstanceRepository(),
|
||||
service: LogCenterService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials,
|
||||
transport: makeTransport(client: SimAdminClient(), sessions: sessions),
|
||||
store: store
|
||||
),
|
||||
store: store,
|
||||
@@ -70,14 +95,15 @@ enum DefaultContainer {
|
||||
|
||||
@MainActor
|
||||
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
|
||||
DeviceViewModel(
|
||||
let sessions = InstanceSessionStore()
|
||||
return DeviceViewModel(
|
||||
instance: instance,
|
||||
service: DeviceService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
|
||||
),
|
||||
probe: ConnectionProbe(client: SimAdminClient())
|
||||
probe: ConnectionProbe(
|
||||
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,23 @@ public struct InstanceSessionService: Sendable {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// Logs in directly and stores the session cookie.
|
||||
///
|
||||
/// Only meaningful in direct mode: a relay instance has no origin to dial and no cookie to keep,
|
||||
/// because the node's agent holds that session itself. Passing one here returns the explicit
|
||||
/// unauthenticated state rather than dialing something arbitrary.
|
||||
public func login(_ instance: Instance, password: String) async throws -> SessionMetadata {
|
||||
guard let origin = instance.endpoint.directOrigin else {
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
|
||||
}
|
||||
do {
|
||||
let response = try await client.send(
|
||||
.authLogin(password: password),
|
||||
baseURL: instance.origin,
|
||||
baseURL: origin,
|
||||
cookie: nil
|
||||
)
|
||||
if let cookie = Self.sessionCookie(response.setCookie) {
|
||||
await store.set(instance.id, origin: instance.origin, cookie: cookie)
|
||||
await store.set(instance.id, origin: origin, cookie: cookie)
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: true, checkedAt: Date())
|
||||
}
|
||||
} catch {
|
||||
@@ -40,15 +48,19 @@ public struct InstanceSessionService: Sendable {
|
||||
}
|
||||
|
||||
public func logout(_ instance: Instance) async throws -> SessionMetadata {
|
||||
if let cookie = await store.cookie(for: instance.id, origin: instance.origin) {
|
||||
_ = try? await client.send(.authLogout, baseURL: instance.origin, cookie: cookie)
|
||||
guard let origin = instance.endpoint.directOrigin else {
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
|
||||
}
|
||||
if let cookie = await store.cookie(for: instance.id, origin: origin) {
|
||||
_ = try? await client.send(.authLogout, baseURL: origin, cookie: cookie)
|
||||
}
|
||||
await store.clear(instance.id)
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
|
||||
}
|
||||
|
||||
public func cookie(for instance: Instance) async -> String? {
|
||||
await store.cookie(for: instance.id, origin: instance.origin)
|
||||
guard let origin = instance.endpoint.directOrigin else { return nil }
|
||||
return await store.cookie(for: instance.id, origin: origin)
|
||||
}
|
||||
|
||||
static func sessionCookie(_ header: String?) -> String? {
|
||||
|
||||
Reference in New Issue
Block a user