Files
multi-simadmin/scripts/export-app-routes.swift
chick 3f6090081f 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)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

121 lines
5.6 KiB
Swift

#!/usr/bin/env swift
//
// Regenerates contracts/app-routes.json in the simadmin-relay repository from UpstreamRoute.
//
// This file exists because the vendored route list and the app's own enum are two copies of one
// fact, and the relay repository's policy test asserts the agent's allowlist equals the vendored
// copy. Without a generator the chain has a hand-edited link in the middle: the policy test would
// pass against a stale file while the app had already grown a route, and the failure would surface
// at runtime as "policy_denied" for an operation the UI offers.
//
// The Swift test `AppRoutesContractTests` checks the reverse direction — that the vendored file
// matches what the app can construct — so the two together make drift a build failure rather than a
// field report.
//
// Usage:
// swift scripts/export-app-routes.swift [output-path]
//
// The default output path assumes the two repositories sit side by side, which is how they are
// checked out here: /Users/chick/.local/opt/multi-simadmin and /Users/chick/.local/opt/simadmin-relay.
import Foundation
// MARK: - The route surface, mirrored from UpstreamRoute
/// One route: the method and the path, with the query shape the app actually sends.
///
/// This is deliberately a separate declaration rather than a parse of `UpstreamRoute.swift`. A
/// parser would have to evaluate Swift to resolve the `path` computed property, and a generator that
/// half-understands the enum is worse than one that states the surface and is checked against it.
struct Route {
let method: String
let path: String
/// True when the route carries a query string the app varies at runtime. The vendored file
/// records the path without the query, because the agent matches on the path prefix and
/// forwards whatever query arrived.
let dynamicQuery: Bool
}
let routes: [Route] = [
Route(method: "GET", path: "/api/health", dynamicQuery: false),
Route(method: "GET", path: "/api/device", dynamicQuery: false),
Route(method: "GET", path: "/api/sim", dynamicQuery: false),
Route(method: "GET", path: "/api/network", dynamicQuery: false),
Route(method: "GET", path: "/api/network/signal-strength", dynamicQuery: false),
Route(method: "GET", path: "/api/stats", dynamicQuery: false),
Route(method: "GET", path: "/api/stats/cpu", dynamicQuery: false),
Route(method: "GET", path: "/api/connectivity", dynamicQuery: false),
Route(method: "GET", path: "/api/sms/list", dynamicQuery: true),
Route(method: "POST", path: "/api/sms/send", dynamicQuery: false),
Route(method: "POST", path: "/api/sms/batch-delete", dynamicQuery: false),
]
/// Login and logout are listed separately and never proxied: the agent logs into its local
/// SimAdmin itself, which is what keeps a node password from ever traversing the relay.
let authRoutes = ["/api/auth/login", "/api/auth/logout"]
// MARK: - Generation
let sourceFile = "apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift"
let defaultOutput = FileManager.default.currentDirectoryPath
.replacingOccurrences(of: "/apps/ios", with: "")
.appending("/../simadmin-relay/contracts/app-routes.json")
let outputPath = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: URL(fileURLWithPath: defaultOutput).standardizedFileURL.path
/// Serialised by hand rather than with `JSONSerialization`, so the file keeps the key order and the
/// two-space indentation a reviewer expects to see in a diff. A generator whose output churns on
/// every run is a generator people stop reading.
var lines: [String] = []
lines.append("{")
lines.append(" \"schemaVersion\": 1,")
lines.append(" \"name\": \"msa-app-routes\",")
lines.append(" \"comment\": \"Vendored copy of every upstream path the iPhone app can construct, derived from UpstreamRoute in multi-simadmin. This file exists so the agent policy can be pinned to what the app actually asks for. Regenerate it, never hand-edit it.\",")
lines.append(" \"source\": {")
lines.append(" \"repository\": \"Hermes/multi-simadmin\",")
lines.append(" \"branch\": \"ios-native-ipa\",")
lines.append(" \"file\": \"\(sourceFile)\",")
lines.append(" \"generator\": \"scripts/export-app-routes.swift\"")
lines.append(" },")
lines.append(" \"auth\": [\(authRoutes.map { "\"\($0)\"" }.joined(separator: ", "))],")
lines.append(" \"routes\": [")
for (index, route) in routes.enumerated() {
let suffix = index == routes.count - 1 ? "" : ","
var entry = " { \"method\": \"\(route.method)\", \"path\": \"\(route.path)\""
if route.dynamicQuery {
entry += ", \"query\": \"dynamic\""
}
entry += " }\(suffix)"
lines.append(entry)
}
lines.append(" ]")
lines.append("}")
let document = lines.joined(separator: "\n") + "\n"
// Validate before writing: a generated file that the policy test would reject is worse than no
// regeneration at all, because it looks authoritative.
guard Set(routes.map(\.path)).count == routes.count else {
FileHandle.standardError.write(Data("export-app-routes: duplicate paths in the route list\n".utf8))
exit(1)
}
for route in routes {
guard route.path.hasPrefix("/api/") else {
FileHandle.standardError.write(Data("export-app-routes: \(route.path) is not under /api/\n".utf8))
exit(1)
}
guard !route.path.contains("?") else {
FileHandle.standardError.write(Data("export-app-routes: \(route.path) carries a query string; the vendored path must not\n".utf8))
exit(1)
}
}
do {
try document.write(toFile: outputPath, atomically: true, encoding: .utf8)
print("wrote \(outputPath)")
} catch {
FileHandle.standardError.write(Data("export-app-routes: \(error)\n".utf8))
exit(1)
}