Compare commits

..
Author SHA1 Message Date
chick ef280b53c9 fix(web): 修复无正文请求被 JSON 解析器拒绝导致"删除节点"失效
控制台所有请求共用的 request() 无条件带上 Content-Type: application/json,
包括无正文的 DELETE /api/v1/instances/:id 与 POST test-connection。Fastify
的 JSON 解析器见到"声明了 JSON 却是空正文"会直接回 400 HTTP_400,请求在
进入路由之前就被拒——所以"删除节点"看起来点了没反应,日志里三次真实尝试
全部是 400,而路由本身完好。

复现(对 8788 实测):
- DELETE + Content-Type: json + 空正文 → 400 HTTP_400(旧行为,坏)
- DELETE 不带 Content-Type          → 401 CONSOLE_AUTH_REQUIRED(到达路由)
- DELETE + Content-Type: json + {}  → 401(能解析,故非路由问题)
- GET    + Content-Type: json + 空   → 401(Fastify 对 GET 不解析正文)

修复:Content-Type 只在确实有正文时才发送。删除的意图本来就在头部
(If-Match / X-Preparation-Id / X-Confirmation-Token)里,无正文属正常形状。

同源的 POST test-connection 也一并修好。iOS 客户端本就用 route.body != nil
判断,与修复后的契约一致——Web 侧是唯一的例外。

回归测试:
- web:断言 DELETE 只发 Accept、不发 Content-Type
- api:断言真实形状到达路由并完成删除(202 且节点转 404),
  同时断言"声称 JSON 却空正文"仍须显式 400、节点必须留存

验证:147 文件 / 1371 测试全绿;lint 干净;typecheck 干净;dist 已重建且
8788 正在服务的 bundle 与磁盘一致(md5 相同)。
2026-09-29 18:49:17 +08:00
chick 3e0bcb1462 docs(ios): 记录端到端测试抓出的三个接缝缺陷 2026-09-16 20:19:30 +08:00
chick d81605ebff docs(ios): 记录 P6 自查结论与修复 2026-09-16 19:20:59 +08:00
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
chick a6867bc9cb docs(ios): §7 配对实现修订——--pair 窗口、agent 生成 bearer、握手后全量上报 2026-09-16 11:43:52 +08:00
chick 99752bd355 docs(ios): 中继实现修订——长轮询替 WSS、响应方向临时×临时、agent 本机登录 2026-09-16 09:15:26 +08:00
chick 95c653b3d3 docs(ios): 双通道(内网直通 + 公网中继)设计稿
- 拓扑:每节点一个 agent,只出站连中继,出口硬编码回环,把 SSRF 爆炸半径从整个内网收缩到单节点
- 信封:Ed25519 签名覆盖 nodeID/reqID/ek 防改投与错配,临时-静态 DH 加密,并写准前向安全的实际边界
- 授权:中继不得授权任何事,危险写确认改由手机产生、agent 校验,说明服务端签发令牌为何不可搬用
- iOS:引入 InstanceEndpoint 与 NodeTransport,origin 同时承担目标/去重键/Cookie 绑定三职的迁移路径
- 复用既有 ProblemDetails、FRESHNESS_STATUSES 与 server/proxy 策略,并要求策略抽为共享 JSON 防漂移
2026-09-15 14:55:45 +08:00
chick 802ddd1102 release(ios): 0.2.2 构建 10 修消息卡空洞、标签胶囊假宽与表单标题层级
- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高
- 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现
- 设置页应用信息补「构建」一行,版本号全程不带括号
- 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带
- 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾
- 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
2026-09-15 13:03:23 +08:00
chick c3d17b3daf release(ios): 0.2.2 构建 9 修复消息发送遮挡并中性化石墨灰阶
- 消息中心发送入口从被标签栏盖住的底部操作条移到导航栏右侧,发信恢复可用
- 停用态主按钮保留主色淡底与同色描边,与次级按钮拉开层级
- 消息行删除改安静线性图标,去掉一屏红块
- 缺数据读数统一为短横,卡片延迟补空格单位
- 中性灰阶改为严格 R=G=B,浅色主题不再读出冷调;图标石墨底同步去蓝
- 详情页延迟量表改用与卡片同一算法,身份信息值改为右对齐
- 标签加号按钮与输入框同高同圆角
2026-09-15 08:19:18 +08:00
chick 9ad0c46088 release(ios): 0.2.2 构建 8 中性化批量标签禁用态并去蓝化图标底色
- FleetTagBatchSheet:无勾选时标签胶囊不再走 Button 禁用态(系统会压成灰块,像加载
  失败),改为中性标签原样渲染;覆盖度文案留空,图标改 tag;新建标签输入与按钮置灰
  并补一句「勾选节点后即可新建标签并批量贴上」。
- 应用图标:底色与插槽从偏青绿的石墨(R>G=B,亮壁纸上读作蓝)改为严格等值中性灰
  20/19/10,金色跨度拉大(#FADD98 -> #C78D2F)去掉芥末黄塑料感;AppIcon.svg 同步。
- 版本号:marketing 0.2.2 不带括号,构建号 7 -> 8;文档里 0.2.x (N) 一律改写为
  「0.2.x 构建 N」,README 与现行说明同步。
2026-09-15 05:07:03 +08:00
chick 36ce791215 polish(ios): 中性化标签与方向胶囊并收紧详情头部间距
- AITag 拆为两类:不传状态色时是中性归类胶囊(tile 底、发丝描边、墨字,可带 10pt 图标),
  传状态色时才用 14% 色雾与 30% 描边。颜色只服务状态语义。
- 详情页标签改用 AITag(tag, systemImage: "tag"),与节点卡片同一枚形状;头部 VStack 间距
  14 -> 12,读数格 minHeight 62 -> 56,加载完成后不再明显小于下方卡片。
- 消息中心「收件 / 发件」由绿/墨配色改回中性胶囊配 arrow.down.left / arrow.up.right,
  绿色重新只表示健康状态。
- 批量标签表在无勾选时给出「先在节点列表勾选要归类的节点」,不再留空白行。
- 文档:0.2.2 增量记录与校验值更新为 0e4ffb74…;版本号后不再带括号,
  0.2.0/0.2.1/0.2.2 与 README、现行说明统一改写为「0.2.x 构建 N」。
2026-09-15 03:56:31 +08:00
chick eb880e0d14 release(ios): 0.2.2 移除分组改用标签归类并完成石墨配色重构
- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理
  instance-groups.json 与 fleet.groupMode
- 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签
- 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义;
  浅色改中性冷灰,深色改低明度石墨三层
- 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊)
- 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿
- 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位
- 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹;
  补齐设备名称/存储总量/可用存储/使用率四个字段名
- 版本号显示去掉括号,设置页只显示 0.2.2
- 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
2026-09-15 02:23:24 +08:00
chick ec3ffb9db3 docs(ios): 同步 0.2.x 实况文档与规划记录 2026-09-14 18:22:23 +08:00
chick 3607eddc9f release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
2026-09-14 16:30:39 +08:00
chick 27687868ef fix(ios): unify toolbar controls and release build 3 2026-09-13 01:15:41 +08:00
chick 2ff23d1fb0 fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid
- Add link-chain state to fleet cards and parse hardware/control/SIM/cellular
- Replace the wrapped icon with a full-bleed app icon
- Bump the IPA build number
2026-09-13 00:46:26 +08:00
chick 409345cba3 docs(ios): document the 0.2.0 release 2026-09-12 17:18:54 +08:00
chick e01a105645 feat(ios): simplify navigation and ship 0.2.0
Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
2026-09-12 17:10:02 +08:00
chick 20632be86a feat(ios): unify action hierarchy and surfaces
Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
2026-09-12 16:15:57 +08:00
chick 0f2866a853 feat(ios): refine native interface and app icon
Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
2026-09-12 14:09:02 +08:00
chick 31b2f33d15 feat(ios): refine adaptive theme and unified controls 2026-09-10 03:19:03 +08:00
chick c841748971 feat(ios): unify list cards and loading states 2026-09-10 01:35:12 +08:00
chick 995206ac23 feat(ios): refine native ui controls 2026-09-09 11:14:39 +08:00
chick c568e496b2 feat(ios): add native settings and storage controls 2026-09-09 02:06:34 +08:00
chick 9c3186252b feat(ios): add cross-instance log center 2026-09-09 01:53:40 +08:00
chick cf187e42fa feat(ios): add cross-instance notification center 2026-09-09 01:13:45 +08:00
chick 84ec3678c0 feat(ios): add device details and reliable ipa build 2026-09-09 00:46:28 +08:00
chick 7778693807 feat(ios): add cross-instance message center 2026-09-09 00:18:56 +08:00
chick 54eb8dcb86 feat(ios): add native app foundation and unsigned ipa build 2026-09-09 00:05:51 +08:00
chick fda3e11f1e docs: add iOS native IPA foundation plan 2026-09-08 21:22:38 +08:00
chick 1caf6985ff docs: add iOS native IPA design 2026-09-08 20:57:54 +08:00
92 changed files with 16443 additions and 3 deletions
+8 -1
View File
@@ -8,5 +8,12 @@ coverage/
apps/web/dist/
.hermes/
.local-run/
artifacts/
apps/ios/.build/
build/
data/
# 误在工作副本里跑构建时留下的缓存目录
apps/ios/apps/
# 图标由 Design/generate_app_icon.swift 重绘,产物不入库(正式资源在 Assets.xcassets)
apps/ios/Design/AppIcon.png
+24
View File
@@ -2,6 +2,30 @@
一个用于聚合管理多个 [SimAdmin](https://github.com/3899/SimAdmin) 实例的轻量本地运维面板。
## iOS Native 应用
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**,构建号 10,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。
底部为节点、消息、设置三个入口:
- **节点**:运行概览、标签多选筛选、标签批量归类、搜索、批量选择、实例增删改,卡片直连实例详情。
- **实例详情**:设备、SIM、蜂窝网络、系统资源、CPU、连接状态六个模块,敏感值默认脱敏,显示后离开页面即隐藏。
- **消息**:跨实例短信读取、按实例与方向筛选、搜索、发送、删除。
- **设置**:主题外观(跟随系统 / 浅色 / 深色)、应用信息、本地数据与日志中心、隐私与数据、数据清理。
实例信息与密码保存在应用沙盒本地文件,会话 Cookie 只存在于进程内存,不使用 Keychain,不上传任何数据。应用只访问用户添加的节点地址,上游路径为固定白名单。
按产品决策移除的能力:全局访问密码、定时与后台调度、iOS 远程推送、自动备份与自动清理、控制台自更新,以及依赖常驻进程的应用内通知中心(日志中心因此收进设置,不再单独占一个底部入口)。
构建与自检:
```bash
./scripts/build-ios-ipa.sh # 产出 build/ipa/MultiSimAdmin.ipa 与 .sha256
cd apps/ios && swift test # 48 个用例
```
文档:现行说明 [docs/ios-native.md](docs/ios-native.md),发布记录 [0.2.2](docs/ios-native-0.2.2.md)、[0.2.1](docs/ios-native-0.2.1.md) 与 [0.2.0](docs/ios-native-0.2.0.md)。
## 功能
- 只需要在 `config.json` 配置多个 SimAdmin 地址。
@@ -226,6 +226,62 @@ describe('instance HTTP routes', () => {
await app.close();
});
it('accepts the bodyless confirmation DELETE the console sends, and rejects a JSON claim', async () => {
// Regression: the console used to send Content-Type: application/json on this bodyless
// request. Fastify then rejected the empty body with 400 HTTP_400 before the route ran, so
// "delete node" silently did nothing. Deletion carries its intent in headers, so a request
// that merely *claims* a JSON body must fail loudly while the real shape must reach the route.
const { app } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
});
const id = (created.json() as { id: string }).id;
const preparation = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'deleteInstance',
targets: [{ instanceId: id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
});
const prepared = preparation.json() as { id: string; confirmationToken: string };
const confirmationHeaders = {
'if-match': '"rev-1"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
};
// The old broken shape: an empty body that still declares itself as JSON.
const claimedJsonBody = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${id}`,
headers: { ...confirmationHeaders, 'content-type': 'application/json' },
});
expect(claimedJsonBody.statusCode).toBe(400);
expect(claimedJsonBody.json()).toMatchObject({ code: 'HTTP_400' });
// The node must survive the rejected attempt.
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
200,
);
// The real console shape: headers only, no Content-Type, no body.
const accepted = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${id}`,
headers: confirmationHeaders,
});
expect(accepted.statusCode).toBe(202);
expect(accepted.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
404,
);
await app.close();
});
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
const { app } = fixture();
const invalid = await app.inject({
+1
View File
@@ -0,0 +1 @@
.build/
+31
View File
@@ -0,0 +1,31 @@
<!-- 设计参考稿,构图与 Design/generate_app_icon.swift 一致;构建实际用 Swift 脚本光栅化 1024 PNG。 -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="field" x1="0.06" y1="0" x2="0.94" y2="1">
<stop offset="0" stop-color="#202020"/>
<stop offset="0.55" stop-color="#131313"/>
<stop offset="1" stop-color="#0A0A0A"/>
</linearGradient>
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FCE5AA"/>
<stop offset="0.46" stop-color="#F0C76F"/>
<stop offset="1" stop-color="#D39B3D"/>
</linearGradient>
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#101010"/>
<stop offset="1" stop-color="#202020"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#field)"/>
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
<path d="M216,403.7 Q216,372 238.4,349.6 L361.6,226.4 Q384,204 415.7,204 L686,204
Q808,204 808,326 L808,674 Q808,796 686,796 L304,796 Q216,796 216,674 Z"
fill="url(#brass)"/>
<!-- 三格插槽:底边对齐,右高,读作多卡队列健康度 -->
<rect x="324" y="504" width="92" height="136" rx="28" fill="url(#slot)"/>
<rect x="470" y="408" width="92" height="232" rx="28" fill="url(#slot)"/>
<rect x="616" y="312" width="92" height="328" rx="28" fill="url(#slot)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+246
View File
@@ -0,0 +1,246 @@
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
// Usage: swift Design/generate_app_icon.swift <output.png>
//
// The mark is the web favicon grown up: a flat gold SIM plate with three
// ascending slots cut out of it, on a neutral graphite field. Two colours, one
// light source from the upper left, no metal texture, so it still reads at the
// 29pt settings size. The plate keeps the classic single SIM cut, top-left.
//
// The field is warm-neutral, not mathematically neutral. A perfectly grey R=G=B
// plate sitting next to saturated gold loses the comparison and reads cool on a
// lit home screen — that is how "there is no blue in it" still looked "blue" to
// the eye. Red runs 2..4 levels over blue instead, which is invisible as a cast
// but impossible to misread. The gold stays warm and deep so it reads metal
// rather than mustard.
//
// Every coordinate is in visual space (origin top-left, y grows down).
import AppKit
import CoreGraphics
import Foundation
let side: CGFloat = 1024
let output = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: "Design/AppIcon.png"
func envDouble(_ name: String, _ fallback: Double) -> CGFloat {
guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else {
return CGFloat(fallback)
}
return CGFloat(value)
}
func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor {
CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
}
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(
data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else {
FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!)
exit(1)
}
ctx.translateBy(x: 0, y: side)
ctx.scaleBy(x: 1, y: -1)
typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int))
func makeGradient(_ stops: [Stop]) -> CGGradient? {
var components: [CGFloat] = []
for stop in stops {
components.append(CGFloat(stop.color.0) / 255)
components.append(CGFloat(stop.color.1) / 255)
components.append(CGFloat(stop.color.2) / 255)
components.append(CGFloat(stop.color.3) / 255)
}
let locations = stops.map(\.location)
return CGGradient(
colorSpace: space,
colorComponents: components,
locations: locations,
count: stops.count
)
}
func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) {
guard let gradient = makeGradient(stops) else { return }
ctx.saveGState()
ctx.addPath(path)
ctx.clip()
ctx.drawLinearGradient(
gradient, start: start, end: end,
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
)
ctx.restoreGState()
}
func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) {
guard let gradient = makeGradient(stops) else { return }
ctx.saveGState()
ctx.drawRadialGradient(
gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius,
options: [.drawsAfterEndLocation]
)
ctx.restoreGState()
}
func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) }
func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
/// SIM plate outline: rounded square with the top-left corner cut at 45 degrees.
func simPath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
let bevel = corner * 0.26
let vertices: [(point: CGPoint, radius: CGFloat)] = [
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
(CGPoint(x: rect.maxX, y: rect.minY), corner),
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
(CGPoint(x: rect.minX, y: rect.maxY), corner),
]
let path = CGMutablePath()
path.move(to: vertices[0].point)
for index in vertices.indices {
path.addArc(
tangent1End: vertices[index].point,
tangent2End: vertices[(index + 1) % vertices.count].point,
radius: vertices[index].radius
)
}
path.closeSubpath()
return path
}
/// Bright along the upper-left edges, gone by the lower-right: an even outline
/// reads like a sticker cut rather than a plate lit from above.
func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) {
ctx.saveGState()
ctx.addPath(path)
ctx.clip()
ctx.addPath(path)
ctx.setLineWidth(width)
ctx.replacePathWithStrokedPath()
ctx.clip()
fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops,
CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY))
ctx.restoreGState()
}
// MARK: - Field
fillLinear(
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
[
// 石墨底走暖一档:R 比 B 高 4 级,肉眼看不出偏色,但再也不可能被读成冷调。
(0.00, (34, 32, 30, 255)),
(0.55, (20, 19, 18, 255)),
(1.00, (11, 10, 10, 255)),
] as [Stop],
CGPoint(x: side * 0.06, y: 0),
CGPoint(x: side * 0.94, y: side)
)
fillRadial(
CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86),
[(0.0, (232, 226, 214, 24)), (0.60, (232, 226, 214, 6)), (1.0, (232, 226, 214, 0))] as [Stop]
)
// MARK: - Plate
let plateSide = envDouble("ICON_PLATE_SIDE", 592)
let plateCenterY = envDouble("ICON_PLATE_Y", 500)
let plateCorner = envDouble("ICON_PLATE_CORNER", 122)
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 168)
let plateRect = CGRect(
x: (side - plateSide) / 2,
y: plateCenterY - plateSide / 2,
width: plateSide,
height: plateSide
)
let plate = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(2, 1, 0, 150))
ctx.addPath(plate)
ctx.setFillColor(rgb(0, 0, 0))
ctx.fillPath()
ctx.restoreGState()
fillLinear(
plate,
[
// 金色往香槟靠,收掉底部的芥末/棕调:小尺寸下这段最容易被读成"脏黄"。
(0.00, (252, 229, 170, 255)),
(0.46, (240, 199, 111, 255)),
(1.00, (211, 155, 61, 255)),
] as [Stop],
CGPoint(x: plateRect.minX, y: plateRect.minY),
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
)
directionalRim(
plate, plateRect, plateSide * 0.007,
[
(0.00, (255, 246, 224, 140)),
(0.36, (255, 240, 206, 32)),
(0.68, (255, 236, 196, 0)),
] as [Stop]
)
// MARK: - Slots
let slotWidth = envDouble("ICON_SLOT_W", 92)
let slotGap = envDouble("ICON_SLOT_GAP", 54)
let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640)
let slotHeights: [CGFloat] = [136, 232, 328]
// 说明:以上默认值等价于 plate 相对比例(宽 .155 / 间距 .091 / 底边内缩 .265),
// 改 plate 尺寸时按同一比例缩放才不会让槽和卡片脱节。
let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap
let slotsStartX = (side - slotsWidth) / 2
for (index, height) in slotHeights.enumerated() {
let rect = CGRect(
x: slotsStartX + CGFloat(index) * (slotWidth + slotGap),
y: slotBottom - height,
width: slotWidth,
height: height
)
let radius = slotWidth * 0.30
let path = roundedPath(rect, radius)
fillLinear(
path,
[
// 插槽与底同色温,避免金面板上的洞看起来发青。
(0.00, (17, 16, 14, 255)),
(1.00, (34, 32, 30, 255)),
] as [Stop],
CGPoint(x: rect.minX, y: rect.minY),
CGPoint(x: rect.minX, y: rect.maxY)
)
directionalRim(
path, rect, radius * 0.30,
[
(0.00, (120, 82, 24, 96)),
(0.52, (120, 82, 24, 16)),
(1.00, (255, 240, 202, 64)),
] as [Stop]
)
}
guard let image = ctx.makeImage() else {
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
exit(1)
}
let rep = NSBitmapImageRep(cgImage: image)
guard let data = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!)
exit(1)
}
try! data.write(to: URL(fileURLWithPath: output))
print(output)
+24
View File
@@ -0,0 +1,24 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MultiSimAdmin",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.executable(name: "MultiSimAdmin", targets: ["MultiSimAdmin"])
],
dependencies: [],
targets: [
.executableTarget(
name: "MultiSimAdmin",
exclude: ["Resources/Info.plist"],
resources: [
.copy("Resources/Assets.xcassets")
]
),
.testTarget(
name: "MultiSimAdminTests",
dependencies: ["MultiSimAdmin"]
)
]
)
+56
View File
@@ -0,0 +1,56 @@
# MultiSimAdmin iOS
iPhone 原生应用的 Swift Package。产品与技术说明见 [docs/ios-native.md](../../docs/ios-native.md),
本文件只写这个目录里怎么动手。
## 目录
```text
Sources/MultiSimAdmin/
AppMetadata.swift 版本号与 Bundle ID 的唯一来源
MultiSimAdminApp.swift 入口、三个底部 Tab、调试直达参数
Models/ 实例、标签、查询等值类型
Features/{Fleet,Device,Messages,Logs,Settings}/ 页面与 view model
Upstream/ 路由白名单、HTTP 客户端、按实例隔离的会话 Cookie
Services/ 连接探测、凭据存储、依赖装配
Persistence/ 沙盒 JSON 文件仓储与日志存储
Theme/ 颜色令牌、按钮与卡片样式、骨架、主题模式
Support/ 错误文案、时间显示、调试启动
Resources/ Info.plist、Assets.xcassets
Tests/MultiSimAdminTests/ 48 个用例
Design/ 图标生成脚本与参考稿
```
## 常用命令
```bash
swift build
swift test
xcodebuild -scheme MultiSimAdmin \
-destination 'platform=iOS Simulator,id=<UDID>' \
-configuration Debug build
swift Design/generate_app_icon.swift \
Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
```
未签名 IPA 从仓库根目录执行 `./scripts/build-ios-ipa.sh` 生成。
## 发版要改的地方
版本号必须四处一致,否则测试会失败:
1. `Sources/MultiSimAdmin/Resources/Info.plist` 的 `CFBundleShortVersionString` 与 `CFBundleVersion`
2. `Sources/MultiSimAdmin/AppMetadata.swift`
3. `Tests/MultiSimAdminTests/AppMetadataTests.swift`
4. `Tests/MultiSimAdminTests/SettingsViewModelTests.swift`
改完补一份 `docs/ios-native-<version>.md` 发布说明,并同步更新 `docs/ios-native.md`、仓库根
README 的版本引用。
## 界面约定
界面不含任何蓝色:主操作、选中态、进度填充一律用石墨墨色(`AITheme.primary` / `AITheme.ink`),
颜色只留给状态语义——危险红、告警琥珀、成功绿,且只在真的表示状态时出现,健康读数保持墨色。
选中态用实心主色加 `onPrimary` 文字,不用低透明度色雾,浅色深色两套主题下都必须一眼可读;
同屏并排控件等高;选中态不得改变控件尺寸;标签统一 28pt 胶囊;卡片内部不再嵌套卡片,
并列读数改用发丝线分隔的栅格;不得出现因配色而在深色背景上消失的按钮或文字。
@@ -0,0 +1,6 @@
public enum AppMetadata {
public static let name = "MultiSimAdmin"
public static let bundleIdentifier = "fun.chick.multi-simadmin"
public static let marketingVersion = "0.2.2"
public static let buildVersion = "10"
}
@@ -0,0 +1,118 @@
import Foundation
public struct DeviceSection: Equatable, Identifiable, Sendable {
public let id: String
public let title: String
public let values: [String: String]
}
public struct DeviceSnapshot: Equatable, Sendable {
public let sections: [DeviceSection]
}
public struct DeviceLinkState: Equatable, Sendable {
public init(
hardwareOnline: Bool? = nil,
controlOnline: Bool? = nil,
simPresent: Bool? = nil,
cellularOnline: Bool? = nil
) {
self.hardwareOnline = hardwareOnline
self.controlOnline = controlOnline
self.simPresent = simPresent
self.cellularOnline = cellularOnline
}
public var hardwareOnline: Bool?
public var controlOnline: Bool?
public var simPresent: Bool?
public var cellularOnline: Bool?
}
extension DeviceService {
static var placeholderSections: [DeviceSection] {
[
DeviceSection(id: "device", title: "设备", values: [:]),
DeviceSection(id: "sim", title: "SIM", values: [:]),
DeviceSection(id: "network", title: "蜂窝网络", values: [:]),
DeviceSection(id: "stats", title: "系统资源", values: [:]),
DeviceSection(id: "cpu", title: "CPU", values: [:]),
DeviceSection(id: "connectivity", title: "连接状态", values: [:])
]
}
}
public struct DeviceSummary: Equatable, Sendable {
public init(
deviceName: String? = nil,
phoneNumber: String? = nil,
operatorName: String? = nil,
networkTechnology: String? = nil,
signalPercent: Int? = nil,
cpuPercent: Double? = nil,
memoryPercent: Double? = nil,
cpuCount: Int? = nil,
temperatureCelsius: Double? = nil,
batteryPercent: Double? = nil,
ipAddress: String? = nil,
latencyMilliseconds: Int? = nil,
connectivity: String? = nil,
firmwareVersion: String? = nil,
totalStorageBytes: Int64? = nil,
availableStorageBytes: Int64? = nil,
downloadRateBytesPerSecond: Double? = nil,
uploadRateBytesPerSecond: Double? = nil,
messagesToday: Int? = nil,
uptimeSeconds: Double? = nil,
linkState: DeviceLinkState = DeviceLinkState()
) {
self.deviceName = deviceName
self.phoneNumber = phoneNumber
self.operatorName = operatorName
self.networkTechnology = networkTechnology
self.signalPercent = signalPercent
self.cpuPercent = cpuPercent
self.memoryPercent = memoryPercent
self.cpuCount = cpuCount
self.temperatureCelsius = temperatureCelsius
self.batteryPercent = batteryPercent
self.ipAddress = ipAddress
self.latencyMilliseconds = latencyMilliseconds
self.connectivity = connectivity
self.firmwareVersion = firmwareVersion
self.totalStorageBytes = totalStorageBytes
self.availableStorageBytes = availableStorageBytes
self.downloadRateBytesPerSecond = downloadRateBytesPerSecond
self.uploadRateBytesPerSecond = uploadRateBytesPerSecond
self.messagesToday = messagesToday
self.uptimeSeconds = uptimeSeconds
self.linkState = linkState
}
public var deviceName: String?
public var phoneNumber: String?
public var operatorName: String?
public var networkTechnology: String?
public var signalPercent: Int?
public var cpuPercent: Double?
public var memoryPercent: Double?
public var cpuCount: Int?
public var temperatureCelsius: Double?
public var batteryPercent: Double?
public var ipAddress: String?
public var latencyMilliseconds: Int?
public var connectivity: String?
public var firmwareVersion: String?
public var totalStorageBytes: Int64?
public var availableStorageBytes: Int64?
public var downloadRateBytesPerSecond: Double?
public var uploadRateBytesPerSecond: Double?
public var messagesToday: Int?
public var uptimeSeconds: Double?
public var linkState: DeviceLinkState
}
public enum DeviceServiceError: Error, Equatable {
case unauthorized
case invalidResponse
}
@@ -0,0 +1,223 @@
import Foundation
public struct DeviceService: Sendable {
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.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
}
public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot {
let routes: [(id: String, title: String, route: UpstreamRoute)] = [
("device", "设备", .device),
("sim", "SIM", .sim),
("network", "蜂窝网络", .network),
("stats", "系统资源", .stats),
("cpu", "CPU", .statsCpu),
("connectivity", "连接状态", .connectivity)
]
var sections: [DeviceSection] = []
var successful = 0
var unauthorized = false
for item in routes {
do {
let response = try await request(item.route, instance)
let values = try Self.flatten(Self.dataObject(response.data))
sections.append(DeviceSection(id: item.id, title: item.title, values: values))
successful += 1
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
unauthorized = true
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
} catch {
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
}
}
if successful == 0 {
throw unauthorized ? DeviceServiceError.unauthorized : DeviceServiceError.invalidResponse
}
return DeviceSnapshot(sections: sections)
}
public func summary(_ instance: Instance) async throws -> DeviceSummary {
let routes: [(id: String, route: UpstreamRoute)] = [
("device", .device),
("sim", .sim),
("network", .network),
("stats", .stats),
("cpu", .statsCpu),
("connectivity", .connectivity)
]
var sections: [(String, [String: String])] = []
var successful = 0
for item in routes {
do {
let response = try await request(item.route, instance)
let values = try Self.flatten(Self.dataObject(response.data))
sections.append((item.id, values))
successful += 1
} catch {
continue
}
}
guard successful > 0 else {
throw DeviceServiceError.invalidResponse
}
return Self.summary(from: sections)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
static func dataObject(_ data: Data) throws -> [String: Any] {
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw DeviceServiceError.invalidResponse
}
if let payload = root["data"] as? [String: Any] { return payload }
if let response = root["response"] as? [String: Any],
let payload = response["data"] as? [String: Any] {
return payload
}
return root
}
static func summary(from sections: [(String, [String: String])]) -> DeviceSummary {
func key(_ sectionID: String, _ names: [String]) -> String? {
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
}
func number(_ text: String?) -> Double? {
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
return Double(text)
}
func bool(_ text: String?) -> Bool? {
switch text?.lowercased() {
case "是", "true", "yes", "1": return true
case "否", "false", "no", "0": return false
default: return nil
}
}
let linkState = DeviceLinkState(
hardwareOnline: bool(key("device", ["powered"])),
controlOnline: bool(key("device", ["online"])),
simPresent: sections.contains { $0.0 == "sim" },
cellularOnline: key("network", ["registration_status"])?
.lowercased()
.hasPrefix("registered")
)
return DeviceSummary(
deviceName: key("device", ["model", "name", "device"]),
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
operatorName: key("network", ["operator_name", "operator", "carrier"]),
networkTechnology: key(
"network",
["network_type", "technology_preference", "technology", "radio_mode"]
),
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
.flatMap { Int($0.rounded()) },
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
Int($0.rounded())
},
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
firmwareVersion: key("device", ["revision", "firmware_version"]),
totalStorageBytes: Self.byteCount(
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
),
availableStorageBytes: Self.byteCount(
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
),
downloadRateBytesPerSecond: Self.byteRate(
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
),
uploadRateBytesPerSecond: Self.byteRate(
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
),
messagesToday: number(
key("connectivity", ["messages_today", "messages", "sms_today"])
).flatMap { Int($0.rounded()) },
uptimeSeconds: number(
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
) ?? number(key("stats", ["uptime_uptime_seconds"]))
?? number(key("stats", ["system_uptime_seconds"])),
linkState: linkState
)
}
private static func byteCount(_ text: String?) -> Int64? {
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
return nil
}
return Int64(value.rounded())
}
private static func byteRate(_ text: String?) -> Double? {
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
return nil
}
return value
}
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
var output: [String: String] = [:]
flatten(source, prefix: "", into: &output, remaining: maximum)
return output
}
private static func flatten(
_ source: [String: Any],
prefix: String,
into output: inout [String: String],
remaining: Int
) {
for (key, value) in source.sorted(by: { $0.key < $1.key }) {
guard output.count < remaining else { return }
let fullKey = prefix.isEmpty ? key : "\(prefix)_\(key)"
switch value {
case let text as String:
output[fullKey] = text
case let flag as Bool:
output[fullKey] = flag ? "是" : "否"
case let number as Int:
output[fullKey] = String(number)
case let number as Double:
output[fullKey] = String(format: "%.1f", number)
case let array as [Any]:
let text = array.compactMap { entry -> String? in
if let record = entry as? [String: Any] {
let preferred = ["name", "zone", "mount_point", "interface", "model_name"]
.compactMap { record[$0] as? String }
.first
if let preferred { return preferred }
}
return entry as? String
}.joined(separator: "、")
output[fullKey] = text
case let nested as [String: Any]:
flatten(nested, prefix: fullKey, into: &output, remaining: remaining - output.count)
default:
break
}
}
}
}
@@ -0,0 +1,41 @@
import Foundation
@MainActor
final class DeviceViewModel: ObservableObject {
@Published var snapshot: DeviceSnapshot?
@Published var isLoading = false
@Published var errorMessage: String?
@Published private(set) var connectionStatus: ConnectionStatus = .unknown
let instance: Instance
private let service: DeviceService
private let probe: ConnectionProbe?
init(instance: Instance, service: DeviceService, probe: ConnectionProbe? = nil) {
self.instance = instance
self.service = service
self.probe = probe
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
snapshot = try await service.snapshot(instance)
} catch DeviceServiceError.unauthorized, UpstreamError.unauthorized {
errorMessage = "需要实例密码登录后才能读取设备数据。"
} catch let error as UpstreamError {
errorMessage = ErrorMessage.describe(error, nodeName: instance.name)
} catch {
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
}
guard let probe else {
connectionStatus = .offline
isLoading = false
return
}
let result = try? await probe.test(instance)
connectionStatus = result?.status ?? .offline
isLoading = false
}
}
@@ -0,0 +1,990 @@
import SwiftUI
struct InstanceDetailView: View {
@StateObject private var model: DeviceViewModel
@State private var revealsSensitive = false
@State private var showsSensitiveInfo = false
init(
instance: Instance,
model: DeviceViewModel? = nil
) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
}
var body: some View {
ScrollView {
VStack(spacing: 12) {
if isUnavailable {
unavailableCard
} else {
headerCard
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
}
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle(model.instance.name)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $showsSensitiveInfo) {
SensitiveValueSheet(
values: sensitiveValues,
isRevealed: $revealsSensitive
)
}
}
// 读取失败且从未拿到数据时,整屏骨架没有意义,改成一屏可读的失败卡片。
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
private var unavailableCard: some View {
VStack(spacing: 12) {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "wifi.slash")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AITheme.danger)
.frame(width: 38, height: 38)
.background(AITheme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
Text("节点无法连接")
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Spacer(minLength: 8)
statusChip(title: "不可达", tint: AITheme.danger)
}
Text(model.errorMessage ?? "无法加载设备数据。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
Button {
Task { await model.refresh() }
} label: {
Label("重新检测", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
}
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
AIKeyValueRow("地址", value: model.instance.endpoint.displayAddress, monospaced: true)
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? model.instance.endpoint.credentialDescription : "未设置")
}
}
// 配置是否丢失不是用户需要被告知的事,卡片本身已经说明了一切。
}
}
private var headerCard: some View {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 12) {
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
HStack(spacing: 8) {
Image(systemName: "globe")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
Text(model.instance.endpoint.displayAddress)
.font(.footnote.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
statusChip()
}
DetailMetricGrid(metrics: gaugeMetrics)
DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading)
moduleAvailability
// 与节点卡片同一枚标签形状(同一胶囊、同一图标),两处只有一处能点。
if model.instance.tags.isEmpty == false {
FlowLayout(spacing: 7) {
ForEach(model.instance.tags, id: \.self) { tag in
AITag(tag, systemImage: "tag")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private func statusChip(title: String? = nil, tint overrideTint: Color? = nil) -> some View {
let loading = model.isLoading && model.snapshot == nil
let tint = overrideTint ?? (loading ? AITheme.muted : statusColor(model.connectionStatus))
let label = title ?? (loading ? "检测中" : model.connectionStatus.label)
return HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: 7, height: 7)
.accessibilityHidden(true)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(tint.opacity(0.12))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityLabel("连接状态 \(label)")
}
/// 模块可用性压成一行读数:六个胶囊和下方分栏标题一一对应,属于纯重复。
/// 只有真的读不到时才展开,直接点名是哪几个模块。
private var moduleAvailability: some View {
let unavailable = moduleSections.filter { $0.values.isEmpty }.map(\.title)
return VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Text("数据模块")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
Spacer()
if let available = availableCount {
Image(systemName: available == moduleSections.count
? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
.accessibilityHidden(true)
Text("\(available)/\(moduleSections.count) 可用")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
} else {
Text("读取中")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
}
}
if unavailable.isEmpty == false {
Text("未返回:" + unavailable.joined(separator: " · "))
.font(.caption2)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private struct HeaderMetric: Identifiable {
let title: String
let value: String?
let systemImage: String
let tint: Color?
let progress: Double?
var id: String { title }
// 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
}
private var allHeaderMetrics: [HeaderMetric] {
let loading = model.isLoading
let phoneValue = stringValue(
section: "sim",
keys: ["phone_numbers", "phone_number"]
)
let storageValue = storageSummary
let uptimeValue = stringValue(
section: "stats",
keys: ["uptime_seconds", "uptime", "system_uptime_seconds"]
).flatMap(durationValue)
let signal = summaryValue(
section: "network",
keys: ["signal_percent", "signal_strength"],
format: loading ? nil : { "\(Int($0.rounded()))%" },
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { signalTint($0) }
)
let latency = summaryValue(
section: "connectivity",
keys: ["latency_ms", "latency"],
format: loading ? nil : { "\(Int($0.rounded())) ms" },
// 进度条读的是"链路质量",和节点卡片同一把尺子(1000ms 归零),
// 之前恒为 1,满格黑条压在 38ms 上会被读成"延迟占满了什么"。
progress: loading ? nil : { max(0.04, min(1, 1 - $0 / 1000)) },
tint: loading ? nil : { latencyTint($0) }
)
let cpu = summaryValue(
section: "cpu",
keys: ["cpu_percent", "cpu_load", "usage"],
format: loading ? nil : { "\(Int($0.rounded()))%" },
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { resourceTint($0) }
)
let memory = summaryValue(
section: "stats",
keys: ["memory_percent", "usage"],
format: loading ? nil : { "\(Int($0.rounded()))%" },
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { resourceTint($0) }
)
return [
makeMetric(
title: "号码",
value: loading ? nil : phoneValue ?? "无数据",
systemImage: "simcard",
tint: loading ? nil : AITheme.ink
),
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
makeMetric(
title: "CPU",
value: loading ? nil : cpu?.value ?? "无数据",
systemImage: "cpu",
tint: loading ? nil : cpu?.tint ?? AITheme.muted,
progress: loading ? nil : cpu?.progress
),
makeMetric(
title: "内存",
value: loading ? nil : memory?.value ?? "无数据",
systemImage: "memorychip",
tint: loading ? nil : memory?.tint ?? AITheme.muted,
progress: loading ? nil : memory?.progress
),
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
]
}
// 头部只保留四块带进度语义的量表,其余信息压缩成文本行。
private static let gaugeOrder: [String] = ["信号", "延迟", "CPU", "内存"]
private var gaugeMetrics: [HeaderMetric] {
let metrics = allHeaderMetrics
return Self.gaugeOrder.compactMap { title in
metrics.first { $0.title == title }
}
}
private var identitySummary: [(label: String, value: String)] {
let metrics = allHeaderMetrics
func derived(_ title: String) -> String? {
metrics.first { $0.title == title }?.value
}
// 顶部只留"这是哪张卡、连在哪个网上"四件事;
// 设备型号、固件、存储、运行时间属于硬件清单,下方分栏里已经逐条列出。
let pairs: [(String, String?)] = [
("号码", derived("号码")),
("运营商", stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])),
("网络", stringValue(
section: "network",
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
)),
("IP", stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"]))
]
return pairs.compactMap { label, value in
guard let value, value.isEmpty == false, value != "无数据" else { return nil }
return (label, value)
}
}
private func makeMetric(
title: String,
value: String?,
systemImage: String,
tint: Color?,
progress: Double? = nil
) -> HeaderMetric {
HeaderMetric(title: title, value: value, systemImage: systemImage, tint: tint, progress: progress)
}
private var storageSummary: String {
guard let total = numericValue(section: "stats", keys: [
"disk_total_bytes", "storage_total_bytes", "total_bytes"
]), let available = numericValue(section: "stats", keys: [
"disk_available_bytes", "storage_available_bytes", "available_bytes"
]), total > 0, available >= 0, total >= available else {
return "无数据"
}
return "\(Int((Double(total - available) / Double(total) * 100).rounded()))% 已用"
}
private var storageTint: Color {
if storageRatio == nil { return AITheme.ink }
return resourceTint(storageRatio! * 100)
}
private var storageProgress: Double {
storageRatio ?? 0
}
private var storageRatio: Double? {
guard let total = numericValue(section: "stats", keys: [
"disk_total_bytes", "storage_total_bytes", "total_bytes"
]), let available = numericValue(section: "stats", keys: [
"disk_available_bytes", "storage_available_bytes", "available_bytes"
]), total > 0, available >= 0, total >= available else {
return nil
}
return Double(total - available) / Double(total)
}
private func numericValue(
section: String,
keys: [String]
) -> Int64? {
stringValue(section: section, keys: keys)
.flatMap { Double($0.replacingOccurrences(of: "%", with: "")) }
.flatMap { Int64($0.rounded()) }
}
private func durationValue(_ text: String) -> String? {
guard let seconds = Double(text.replacingOccurrences(of: "%", with: "")), seconds >= 0 else {
return nil
}
return Duration.seconds(seconds).formatted(
.units(allowed: [.days, .hours, .minutes], width: .abbreviated)
)
}
private func statusColor(_ value: ConnectionStatus) -> Color {
switch value {
case .online: return AITheme.success
case .degraded, .authRequired: return AITheme.warning
case .offline: return AITheme.danger
case .unknown: return AITheme.muted
}
}
private struct FormattedMetric {
let value: String
let tint: Color
let progress: Double
}
private func stringValue(
section: String,
keys: [String]
) -> String? {
guard let values = model.snapshot?.sections.first(where: { $0.id == section })?.values else {
return nil
}
return keys.lazy.compactMap { values[$0] }.first { !$0.isEmpty }
}
private func summaryValue(
section: String,
keys: [String],
format: ((Double) -> String)?,
progress: ((Double) -> Double)?,
tint: ((Double) -> Color)?
) -> FormattedMetric? {
guard let snapshot = model.snapshot,
let values = snapshot.sections.first(where: { $0.id == section })?.values
else { return nil }
let raw = keys.compactMap { values[$0] }.first { !$0.isEmpty }
guard let raw, let number = Double(raw.replacingOccurrences(of: "%", with: "")), number >= 0 else {
return nil
}
guard let format, let progress, let tint else { return nil }
return FormattedMetric(
value: format(number),
tint: tint(number),
progress: progress(number)
)
}
/// 阈值只回答"要不要提醒",健康值不着色,四格量表不会染成一片绿。
private func signalTint(_ value: Double) -> Color {
value >= 60 ? AITheme.ink : (value >= 30 ? AITheme.warning : AITheme.danger)
}
private func latencyTint(_ value: Double) -> Color {
value < 300 ? AITheme.ink : (value < 800 ? AITheme.warning : AITheme.danger)
}
private func resourceTint(_ value: Double) -> Color {
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.ink)
}
private struct DetailHeaderMetric: View {
let metric: HeaderMetric
var title: String { metric.title }
var value: String? { metric.value }
var systemImage: String { metric.systemImage }
var tint: Color { metric.tint ?? AITheme.muted }
var progress: Double { metric.progress ?? 0 }
var isProgressVisible: Bool {
metric.title == "连接状态" || metric.progress != nil
}
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(metric.isAlert ? tint : AITheme.faint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
if let value {
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(metric.isAlert ? tint : AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.minimumScaleFactor(0.72)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.55))
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
// 读数在上、进度在下,缺数据时也留一条轨道,数值到位后格子不跳动。
if isProgressVisible {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous).fill(AITheme.line)
Capsule(style: .continuous)
.fill(metric.isAlert ? tint : AITheme.primary)
.frame(width: max(3, proxy.size.width * CGFloat(min(1, max(0, progress)))))
}
}
.frame(height: 4)
.accessibilityHidden(true)
} else {
Color.clear
.frame(height: 4)
}
}
.frame(maxWidth: .infinity, minHeight: 56, alignment: .topLeading)
.accessibilityElement(children: .combine)
}
}
/// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。
private struct DetailMetricGrid: View {
let metrics: [HeaderMetric]
private var rows: [[HeaderMetric]] {
stride(from: 0, to: metrics.count, by: 2).map {
Array(metrics[$0..<min($0 + 2, metrics.count)])
}
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, rowMetrics in
if rowIndex > 0 {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
HStack(alignment: .top, spacing: 0) {
ForEach(Array(rowMetrics.enumerated()), id: \.element.id) { columnIndex, metric in
DetailHeaderMetric(metric: metric)
.padding(.leading, columnIndex == 0 ? 0 : 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
if rowMetrics.count < 2 {
Color.clear
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 10)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
private struct DetailIdentityStrip: View {
let items: [(label: String, value: String)]
let isLoading: Bool
var body: some View {
if isLoading {
// 首屏还没有数据时用骨架条,避免出现一整列"读取中"。
VStack(alignment: .leading, spacing: 9) {
ForEach(0..<3, id: \.self) { _ in
HStack(spacing: 8) {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.7))
.frame(width: 42, height: 10)
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 10)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityLabel("设备信息读取中")
} else if items.isEmpty == false {
VStack(alignment: .leading, spacing: 6) {
ForEach(items, id: \.label) { item in
// 值一律右对齐,和下方分区卡片的键值行同一节奏;
// 之前左边缩进 52pt、值顶在中间,同屏两种排法读起来像两张不同的表。
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: true, vertical: false)
Spacer(minLength: 12)
Text(item.value)
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.multilineTextAlignment(.trailing)
.layoutPriority(1)
}
.accessibilityElement(children: .combine)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.padding(.top, 1)
}
}
}
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
HStack(spacing: 4) {
Circle()
.fill(isAvailable ? AITheme.success : AITheme.control)
.frame(width: 6, height: 6)
.overlay {
Circle()
.strokeBorder(AITheme.line)
}
Text(title)
.font(.caption2.weight(.medium))
.foregroundStyle(isAvailable ? AITheme.ink : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
// FlowLayout 按固有尺寸排布,这里不能再用 maxWidth: .infinity。
private struct FlowLayout: Layout {
let spacing: CGFloat
var lineSpacing: CGFloat?
private var rowGap: CGFloat { lineSpacing ?? spacing }
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var rowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight + rowGap
x = 0
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight
return CGSize(width: max(0, widestRow), height: max(0, totalHeight))
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + rowGap
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
private var availableCount: Int? {
guard let snapshot = model.snapshot else { return nil }
return snapshot.sections.filter { !$0.values.isEmpty }.count
}
private var moduleSections: [DeviceSection] {
model.snapshot?.sections ?? DeviceService.placeholderSections
}
private var sensitiveValues: [(title: String, value: String)] {
guard let snapshot = model.snapshot else { return [] }
return snapshot.sections.flatMap { section in
section.values
.filter { sensitiveKeys.contains($0.key) }
.sorted { $0.key < $1.key }
.map { (fieldLabel($0.key), $0.value) }
}
}
@ViewBuilder
private func sectionCard(_ section: DeviceSection) -> some View {
if section.values.isEmpty {
AICard(padding: 14) {
HStack(spacing: 10) {
Image(systemName: "wifi.slash")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 32, height: 32)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Text("模块未返回数据")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
}
} else {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
// 行与行只靠发丝线分隔,纵向节奏交给 row 自己的内边距,
// 否则卡片会越拉越长,一屏读不到两组数据。
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
.padding(.vertical, 9)
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
}
}
}
}
private func sectionIcon(_ id: String) -> String {
switch id {
case "device": return "iphone.gen3"
case "sim": return "simcard"
case "network": return "antenna.radiowaves.left.and.right"
case "stats": return "chart.bar.fill"
case "cpu": return "cpu"
case "connectivity": return "dot.radiowaves.left.and.right"
default: return "list.bullet.rectangle"
}
}
private let fieldLabels: [String: String] = [
"model": "型号",
"manufacturer": "制造商",
"imei": "IMEI",
"revision": "固件版本",
"online": "在线",
"powered": "基带电源",
"present": "SIM 在位",
"iccid": "ICCID",
"imsi": "IMSI",
"phone_numbers": "号码",
"sms_center": "短信中心",
"operator_name": "运营商",
"registration_status": "注册状态",
"technology_preference": "网络制式",
"signal_strength": "信号强度",
"brand": "品牌",
"meid": "MEID",
"phone_number": "号码",
"firmware_version": "固件版本",
"os_version": "系统版本",
"baseband_version": "基带版本",
"uptime": "运行时间",
"uptime_seconds": "运行时间",
"slots": "卡槽数",
"active_slots": "活跃数",
"active": "已激活",
"enabled": "已启用",
"operator": "运营商",
"carrier": "运营商",
"network_type": "接入制式",
"technology": "技术",
"radio_mode": "网络模式",
"signal": "信号强度",
"signal_dbm": "信号强度(dBm)",
"signal_percent": "信号质量",
"rsrp": "RSRP",
"rsrq": "RSRQ",
"sinr": "SINR",
"mcc": "MCC",
"mnc": "MNC",
"registration": "注册状态",
"registration_state": "注册状态",
"roaming": "漫游",
"airplane_mode": "飞行模式",
"ipv4": "IPv4 地址",
"ipv6": "IPv6 地址",
"ip_address": "IP 地址",
"gateway": "网关",
"dns": "DNS",
"download": "下行速率",
"upload": "上行速率",
"download_bytes": "下行流量",
"upload_bytes": "上行流量",
"messages_today": "今日消息数",
"calls": "通话数",
"usage": "使用率",
"temperature": "温度",
"max_temperature_c": "最高温度",
"cpu_percent": "CPU 使用率",
"cpu_load": "CPU 负载",
"memory_percent": "内存使用率",
"memory_total_mb": "内存总量",
"memory_available_mb": "可用内存",
"battery_level": "电量",
"battery_percent": "电量",
"latency": "延迟",
"connected": "连接状态",
"interface": "网络接口",
"ssid": "无线网络",
"apn": "APN",
"core_count": "核心数",
"model_name": "CPU 型号",
"state": "连接状态",
"latency_ms": "延迟(ms)",
"name": "设备名称",
"disk_total_bytes": "存储总量",
"disk_available_bytes": "可用存储",
"percent": "使用率"
]
private let valueOrder: [String] = [
"model", "manufacturer", "brand", "name", "imei", "meid", "revision", "firmware_version",
"online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi",
"phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier",
"registration_status", "registration_state", "technology_preference", "network_type",
"radio_mode", "signal_strength", "signal_dbm", "signal_percent", "rsrp", "rsrq", "sinr",
"state", "connected", "latency", "latency_ms", "uptime", "uptime_seconds", "cpu_percent",
"cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage",
"temperature", "max_temperature_c", "battery_level", "battery_percent", "download",
"upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway",
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls",
"disk_total_bytes", "disk_available_bytes", "percent"
]
private let durationKeys: Set<String> = ["uptime", "uptime_seconds"]
private let percentKeys: Set<String> = ["usage", "cpu_percent", "memory_percent"]
private let byteRateKeys: Set<String> = ["download", "upload"]
private let byteCountKeys: Set<String> = ["download_bytes", "upload_bytes"]
private let sensitiveKeys: Set<String> = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"]
private func sortedValues(_ values: [String: String]) -> [(key: String, value: String)] {
values
.map { (key: $0.key, value: $0.value) }
.sorted { lhs, rhs in
let lhsRank = valueOrder.firstIndex(of: lhs.key) ?? Int.max
let rhsRank = valueOrder.firstIndex(of: rhs.key) ?? Int.max
if lhsRank != rhsRank { return lhsRank < rhsRank }
return lhs.key < rhs.key
}
}
private func fieldLabel(_ key: String) -> String {
if let exact = fieldLabels[key] { return exact }
let parts = key.split(separator: "_").map(String.init)
for count in [2, 1] where parts.count >= count {
let suffix = parts.suffix(count).joined(separator: "_")
if let label = fieldLabels[suffix] { return label }
}
return parts.joined(separator: " ")
}
private func display(_ key: String, _ value: String) -> String {
if matches(key, durationKeys), let seconds = Double(value), seconds >= 0 {
return Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated))
}
if matches(key, percentKeys), let number = Double(value), number >= 0 {
return "\(number.formatted(.number.precision(.fractionLength(0...1))))%"
}
if matches(key, byteRateKeys), let bytesPerSecond = Double(value), bytesPerSecond >= 0 {
return ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s"
}
if matches(key, byteCountKeys), let bytes = Double(value), bytes >= 0 {
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .decimal)
}
if matches(key, ["memory_total_mb"]), let megabytes = Double(value) {
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
}
if matches(key, ["memory_available_mb"]), let megabytes = Double(value) {
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
}
if key.hasSuffix("temperature"), let temperature = Double(value) {
return "\(temperature.formatted(.number.precision(.fractionLength(0...1))))°C"
}
if sensitiveKeys.contains(key), !revealsSensitive {
return masked(value)
}
return value
}
private func masked(_ value: String) -> String {
guard let first = value.first, value.count > 6 else {
return String(repeating: "•", count: min(value.count, 8))
}
let tail = value.suffix(4)
return "\(first) •••• \(tail)"
}
private func matches(_ key: String, _ keys: Set<String>) -> Bool {
let parts = key.split(separator: "_").map(String.init)
let suffixes = [key] + (1...2).compactMap { count in
parts.count >= count ? parts.suffix(count).joined(separator: "_") : nil
}
return suffixes.contains { keys.contains($0) }
}
private struct SensitiveValueSheet: View {
let values: [(title: String, value: String)]
@Binding var isRevealed: Bool
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
if values.isEmpty {
AICard {
AIEmptyState(
systemImage: "lock.open",
title: "暂无敏感值",
message: "设备数据加载后,这里会集中显示 IMEI、ICCID 等信息。"
)
}
} else {
AICard {
VStack(alignment: .leading, spacing: 14) {
Toggle(isOn: $isRevealed) {
VStack(alignment: .leading, spacing: 2) {
Text("显示敏感值")
.font(.subheadline.weight(.semibold))
Text("离开页面或手动关闭后自动隐藏。")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
.toggleStyle(.switch)
.tint(AITheme.primary)
Divider()
.overlay(AITheme.line.opacity(0.6))
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
}
AIKeyValueRow(
item.title,
value: isRevealed ? item.value : masked(item.value)
)
}
}
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("敏感值")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("完成") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func masked(_ value: String) -> String {
guard let first = value.first, value.count > 6 else {
return String(repeating: "•", count: min(value.count, 8))
}
let tail = value.suffix(4)
return "\(first) •••• \(tail)"
}
}
}
@@ -0,0 +1,225 @@
import SwiftUI
/// 分组被移除后,批量归类这件事由标签承担:一次点选给多台节点贴同一个标签。
struct FleetTagBatchSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var draft = ""
@State private var isBusy = false
private var targets: [UUID] { model.selectedIDs.sorted { $0.uuidString < $1.uuidString } }
private var selectedNames: [String] {
model.rows.filter { model.selectedIDs.contains($0.id) }.map(\.instance.name).sorted {
$0.localizedCompare($1) == .orderedAscending
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
targetsCard
tagGridCard
newTagCard
}
.padding(16)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("批量标签")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
AIToolbarButton("完成", kind: .primary) { dismiss() }
}
}
}
}
private var targetsCard: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("已选 \(targets.count) 台", systemImage: "checklist")
if targets.isEmpty {
Text("先在节点列表勾选要归类的节点。")
.font(.caption)
.foregroundStyle(AITheme.muted)
} else {
Text(selectedNames.joined(separator: "、"))
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(3)
.truncationMode(.tail)
}
}
}
}
private var tagGridCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("现有标签", systemImage: "tag")
if model.allTags.isEmpty {
Text("还没有标签,先在下方新建一个。")
.font(.caption)
.foregroundStyle(AITheme.faint)
} else {
// 与节点卡片的标签行同一排法:胶囊按标签长度取宽。栅格会把每枚拉到
// 同一列宽,短标签右侧空出一片,看起来像控件没加载完。
AIFlowLayout(spacing: 8) {
ForEach(model.allTags, id: \.self) { tag in
CoverageChip(
title: tag,
covered: model.tagCoverage(tag, in: targets),
total: targets.count,
isBusy: isBusy
) {
Task { await apply(tag, removing: model.tagCoverage(tag, in: targets) == targets.count) }
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private var newTagCard: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AIFieldLabel("新建标签并贴上")
HStack(spacing: 10) {
TextField("如 深圳-现场", text: $draft)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("新标签名称")
.submitLabel(.done)
.onSubmit { submitDraft() }
.disabled(isBusy || targets.isEmpty)
Button(isBusy ? "处理中" : "添加") { submitDraft() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fixedWidth: 84))
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isBusy || targets.isEmpty)
}
// 没有勾选节点时这个表单按下去只会清空输入,先说清楚为什么不能动。
if targets.isEmpty {
Text("勾选节点后即可新建标签并批量贴上。")
.font(.caption2)
.foregroundStyle(AITheme.faint)
}
}
}
}
private func submitDraft() {
let tag = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard tag.isEmpty == false, isBusy == false else { return }
draft = ""
Task { await apply(tag, removing: false) }
}
private func apply(_ tag: String, removing: Bool) async {
guard isBusy == false, targets.isEmpty == false else { return }
isBusy = true
await model.applyTag(tag, to: targets, removing: removing)
isBusy = false
}
/// 覆盖状态直接写在胶囊上:全部已贴 / 部分已贴 / 未贴,点击即切换整批。
private struct CoverageChip: View {
let title: String
let covered: Int
let total: Int
let isBusy: Bool
let action: () -> Void
private var isComplete: Bool { total > 0 && covered == total }
private var isPartial: Bool { covered > 0 && covered < total }
// 没有勾选任何节点时这一整片都是不可操作状态,此时不该用「未贴」这种带暗示的
// 灰字,直接按中性标签渲染,避免看起来像加载失败的禁用控件。
private var isInert: Bool { total == 0 }
private var stateText: String {
if isInert { return "" }
if isComplete { return "全部" }
if isPartial { return "\(covered)/\(total)" }
return "未贴"
}
var body: some View {
if isInert {
// 不可操作时不渲染 Button:系统禁用态会把整枚胶囊压成灰色,
// 看起来像加载失败,而标签本身是需要读清楚的内容。
label
.accessibilityLabel("标签 \(title),勾选节点后可贴上")
} else {
Button(action: action) { label }
.buttonStyle(.plain)
.disabled(isBusy)
.accessibilityLabel(isComplete ? "从已选节点移除标签 \(title)" : "给已选节点贴上标签 \(title)")
.accessibilityAddTraits(isComplete ? [.isButton, .isSelected] : .isButton)
}
}
private var label: some View {
HStack(spacing: 6) {
Image(systemName: iconName)
.font(.system(size: 12, weight: .semibold))
.accessibilityHidden(true)
Text(title)
.font(.footnote.weight(isComplete || isPartial ? .semibold : .medium))
.lineLimit(1)
.truncationMode(.middle)
if stateText.isEmpty == false {
Text(stateText)
.font(.caption2.weight(.semibold))
.monospacedDigit()
.opacity(0.72)
}
}
.padding(.horizontal, 10)
.frame(height: 38)
.foregroundStyle(foreground)
.background(background)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(border)
}
.contentShape(Capsule(style: .continuous))
}
private var iconName: String {
if isInert { return "tag" }
if isComplete { return "checkmark.circle.fill" }
if isPartial { return "circle.lefthalf.filled" }
return "circle"
}
private var foreground: Color {
if isInert { return AITheme.ink }
if isComplete { return AITheme.onPrimary }
if isPartial { return AITheme.ink }
return AITheme.muted
}
private var background: Color {
if isInert { return AITheme.tile }
if isComplete { return AITheme.primary }
if isPartial { return AITheme.tile }
return AITheme.surface
}
private var border: Color {
if isInert { return AITheme.tileLine }
if isComplete { return AITheme.primary }
if isPartial { return AITheme.primary.opacity(0.42) }
return AITheme.line
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
import Foundation
import SwiftUI
@MainActor
final class FleetViewModel: ObservableObject {
struct Row: Equatable, Identifiable {
let id: UUID
var instance: Instance
var status: ConnectionStatus
var deviceName: String?
var phoneNumber: String?
var operatorName: String?
var networkTechnology: String?
var signalPercent: Int?
var cpuPercent: Double?
var memoryPercent: Double?
var cpuCount: Int?
var temperatureCelsius: Double?
var batteryPercent: Double?
var ipAddress: String?
var latencyMilliseconds: Int?
var connectivity: String?
var firmwareVersion: String?
var totalStorageBytes: Int64?
var availableStorageBytes: Int64?
var downloadRateBytesPerSecond: Double?
var uploadRateBytesPerSecond: Double?
var messagesToday: Int?
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 {
return nil
}
return Double(max(0, total - available)) / Double(total)
}
init(
id: UUID,
instance: Instance,
status: ConnectionStatus = .unknown,
testing: Bool = true
) {
self.id = id
self.instance = instance
self.status = status
self.testing = testing
}
}
@Published var rows: [Row] = []
@Published var searchText = ""
@Published var statusFilter: ConnectionStatus?
@Published var selectedTagFilters: Set<String> = [] {
didSet {
let values = Array(selectedTagFilters).sorted()
preferences.set(values, forKey: "fleet.selectedTagFilters")
}
}
@Published var selectedIDs: Set<UUID> = []
@Published var selectionMode = false
@Published var isLoading = false
@Published private(set) var lastRefresh: Date?
@Published var errorMessage: String?
@Published var showingForm = false
@Published var showingTagBatch = false
private let repository: any InstanceRepository
private let probe: ConnectionProbe
private let credentials: any CredentialStore
private let deviceService: (any DeviceSummaryService)?
private let preferences: UserDefaults
init(
repository: any InstanceRepository,
probe: ConnectionProbe,
credentials: any CredentialStore,
deviceService: (any DeviceSummaryService)? = nil,
preferences: UserDefaults = .standard
) {
self.repository = repository
self.probe = probe
self.credentials = credentials
self.deviceService = deviceService
self.preferences = preferences
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
}
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
func pruneTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
if live != selectedTagFilters {
selectedTagFilters = live
}
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
let instances = try await repository.list(query)
rows = instances.map {
Row(id: $0.id, instance: $0, status: .unknown, testing: true)
}
await withTaskGroup(of: Void.self) { group in
for instance in instances {
group.addTask { [probe, deviceService] in
let result = try? await probe.test(instance)
let summary = try? await deviceService?.summary(instance)
await self.applyProbe(instance.id, result, summary)
}
}
}
} catch {
errorMessage = "无法刷新节点列表"
}
pruneTagFilters()
lastRefresh = Date()
isLoading = false
}
@discardableResult
func add(_ input: InstanceInput) async throws -> Instance {
let created = try await repository.create(input)
if let password = input.password, !password.isEmpty {
try credentials.set(instanceID: created.id, password: password)
}
showingForm = false
await refresh()
return created
}
func update(_ id: UUID, _ input: InstanceInput) async throws {
guard let original = rows.first(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
_ = try await repository.update(
id,
expectedRevision: original.instance.configRevision,
InstancePatch(
name: input.name,
endpoint: input.endpoint,
tags: input.tags,
password: input.password
)
)
if let password = input.password, !password.isEmpty {
try credentials.set(instanceID: id, password: password)
}
showingForm = false
await refresh()
}
func delete(_ ids: [UUID]) async {
for id in ids {
if let row = rows.first(where: { $0.id == id }) {
do {
try await repository.delete(id, expectedRevision: row.instance.configRevision)
try credentials.remove(instanceID: id)
} catch {
errorMessage = "删除 \(row.instance.name) 失败"
}
}
}
selectedIDs.removeAll()
selectionMode = false
await refresh()
}
func deleteSelected() async {
await delete(Array(selectedIDs))
}
/// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。
func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
var failures = 0
for id in ids {
guard let row = rows.first(where: { $0.id == id }) else { continue }
var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized }
if removing == false {
updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines))
}
do {
_ = try await repository.update(
id,
expectedRevision: row.instance.configRevision,
InstancePatch(tags: updated)
)
} catch {
failures += 1
errorMessage = "更新 \(row.instance.name) 标签失败"
}
}
if failures > 0, errorMessage == nil {
errorMessage = "部分节点标签更新失败"
}
await refresh()
}
func tagNames(in ids: [UUID]) -> [String] {
let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) })
return set.sorted { $0.localizedCompare($1) == .orderedAscending }
}
private func applyProbe(
_ id: UUID,
_ result: ConnectionResult?,
_ summary: DeviceSummary?
) {
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
rows[index].operatorName = summary.operatorName
rows[index].networkTechnology = summary.networkTechnology
rows[index].signalPercent = summary.signalPercent
rows[index].cpuPercent = summary.cpuPercent
rows[index].memoryPercent = summary.memoryPercent
rows[index].cpuCount = summary.cpuCount
rows[index].temperatureCelsius = summary.temperatureCelsius
rows[index].batteryPercent = summary.batteryPercent
rows[index].ipAddress = summary.ipAddress
rows[index].latencyMilliseconds = summary.latencyMilliseconds
rows[index].connectivity = summary.connectivity
rows[index].firmwareVersion = summary.firmwareVersion
rows[index].totalStorageBytes = summary.totalStorageBytes
rows[index].availableStorageBytes = summary.availableStorageBytes
rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond
rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond
rows[index].messagesToday = summary.messagesToday
rows[index].uptimeSeconds = summary.uptimeSeconds
rows[index].chainValues = summary.linkState
}
if rows[index].latencyMilliseconds == nil {
rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds
}
rows[index].testing = false
}
/// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。
var visibleRows: [Row] {
rows.filter { row in
if let statusFilter, row.status != statusFilter {
return false
}
if selectedTagFilters.isEmpty == false {
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
guard needles.isSubset(of: tags) else {
return false
}
}
guard !searchText.isEmpty else { return true }
let haystacks: [String] = [
row.instance.name,
row.instance.endpoint.displayAddress,
row.instance.tags.joined(separator: " "),
row.operatorName ?? "",
row.phoneNumber ?? "",
row.networkTechnology ?? "",
row.deviceName ?? ""
]
let strings: [String] = haystacks
return strings.contains {
$0.localizedCaseInsensitiveContains(searchText)
}
}
.sorted { lhs, rhs in
let left = severity(lhs.status)
let right = severity(rhs.status)
if left != right { return left < right }
return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending
}
}
private func severity(_ status: ConnectionStatus) -> Int {
switch status {
case .offline: return 0
case .authRequired, .degraded: return 1
case .unknown: return 2
case .online: return 3
}
}
var allTags: [String] {
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
}
var tagCounts: [String: Int] {
var counts: [String: Int] = [:]
for row in rows {
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
counts[tag, default: 0] += 1
}
}
return counts
}
func countForTagFilter(_ tag: String) -> Int {
tagCounts[Self.normalizedTag(tag)] ?? 0
}
func isTagFilterActive(_ tag: String) -> Bool {
selectedTagFilters.contains(Self.normalizedTag(tag))
}
/// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。
func tagCoverage(_ tag: String, in ids: [UUID]) -> Int {
let needle = Self.normalizedTag(tag)
guard needle.isEmpty == false else { return 0 }
let targets = Set(ids)
return rows.filter { row in
targets.contains(row.id)
&& row.instance.tags.map(Self.normalizedTag).contains(needle)
}.count
}
func toggleTagFilter(_ tag: String) {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
if selectedTagFilters.contains(normalized) {
selectedTagFilters.remove(normalized)
} else {
selectedTagFilters.insert(normalized)
}
}
func clearTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
selectedTagFilters.removeAll()
}
private static func normalizedTag(_ tag: String) -> String {
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
protocol DeviceSummaryService {
func summary(_ instance: Instance) async throws -> DeviceSummary
}
extension DeviceService: DeviceSummaryService {}
@@ -0,0 +1,193 @@
import SwiftUI
struct InstanceFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
let instance: Instance?
@State private var name = ""
@State private var originText = ""
@State private var tags: [String] = []
@State private var tagDraft = ""
@State private var password = ""
@State private var validationMessage: String?
@State private var isSaving = false
init(model: FleetViewModel, instance: Instance? = nil) {
self.model = model
self.instance = instance
_name = State(initialValue: instance?.name ?? "")
_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
&& (isRelay || !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
if isRelay {
relayChannelRow
} else {
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
}
tagEditor
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")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
save()
} label: {
HStack(spacing: 8) {
if isSaving {
ProgressView()
.controlSize(.small)
}
Text(isSaving ? "保存中" : "保存实例")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave || isSaving)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
#if os(iOS)
// 表单表统一用行内标题:大标题会在卡片上方留出一整条空白,
// 而「批量标签」等其它表已经是行内样式。
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
/// 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("标签")
TagEditorView(tags: $tags, suggestions: model.allTags)
}
.accessibilityElement(children: .contain)
}
private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel(title)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.textContentType(.password)
.accessibilityLabel(title)
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel(title)
.submitLabel(.done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
}
}
}
private func save() {
guard !isSaving else { return }
isSaving = true
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https",
origin.host != nil
else {
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
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,
endpoint: endpoint,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
if let instance {
try await model.update(instance.id, input)
} else {
try await model.add(input)
}
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
isSaving = false
}
}
}
@@ -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。"
}
}
}
@@ -0,0 +1,161 @@
import SwiftUI
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
struct TagEditorView: View {
@Binding var tags: [String]
let suggestions: [String]
@State private var draft = ""
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
TextField("新标签,回车添加", text: $draft)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
#endif
.onSubmit { commit(draft) }
Button {
commit(draft)
} label: {
Image(systemName: "plus")
// 加号按钮和左边的输入框同一高度、同一圆角,两个控件才读得出是一组。
.font(.system(size: 16, weight: .semibold))
.frame(width: AITheme.inputHeight, height: AITheme.inputHeight)
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
}
.buttonStyle(.plain)
.disabled(canAdd == false)
.accessibilityLabel("添加标签")
}
if tags.isEmpty == false || suggestions.isEmpty == false {
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
if tags.isEmpty == false {
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
removableTag(tags[index])
}
}
if availableSuggestions.isEmpty == false {
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
suggestionTag(availableSuggestions[index])
}
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("标签")
}
private func chipStrip<Item: View>(
ids: [String],
@ViewBuilder content: @escaping (Int) -> Item
) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
content(index)
}
}
.padding(.vertical, 2)
}
.accessibilityHidden(false)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canAdd: Bool {
trimmedDraft.isEmpty == false
}
private var availableSuggestions: [String] {
let taken = Set(tags.map(Self.key))
return suggestions
.filter { taken.contains(Self.key($0)) == false }
.prefix(10)
.map { $0 }
}
private func removableTag(_ tag: String) -> some View {
Button {
tags.removeAll { Self.key($0) == Self.key(tag) }
} label: {
HStack(spacing: 5) {
Text(tag)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.opacity(0.65)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.ink)
.background(AITheme.control)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.line)
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("移除标签 \(tag)")
}
private func suggestionTag(_ tag: String) -> some View {
Button {
add(tag)
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 10, weight: .bold))
Text(tag)
.font(.footnote.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.primary)
.background(AITheme.primary.opacity(0.09))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("使用已有标签 \(tag)")
}
private func commit(_ raw: String) {
let parts = raw
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
parts.forEach(add)
draft = ""
}
private func add(_ tag: String) {
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.isEmpty == false else { return }
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
tags.append(value)
}
private static func key(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
@@ -0,0 +1,218 @@
import Foundation
public enum RuntimeLogSource: String, Codable, Equatable, Sendable, CaseIterable {
case delivery
public var label: String {
switch self {
case .delivery: return "节点运行"
}
}
}
public enum RuntimeLogLevel: String, Codable, Equatable, Sendable, CaseIterable {
case info
case warning
case error
public var label: String {
switch self {
case .info: return "信息"
case .warning: return "警告"
case .error: return "错误"
}
}
}
public enum ConnectionOutcome: String, Codable, Equatable, Sendable, CaseIterable {
case success
case stale
case failed
case unsupported
public var label: String {
switch self {
case .success: return "成功"
case .stale: return "需登录"
case .failed: return "失败"
case .unsupported: return "不支持"
}
}
}
public struct RuntimeLogEntry: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let source: RuntimeLogSource
public let level: RuntimeLogLevel
public let message: String
public let eventType: String?
public let instanceID: UUID?
public let instanceName: String?
public let occurredAt: Date
public let code: String?
public let durationMs: Int?
public init(
id: String,
source: RuntimeLogSource,
level: RuntimeLogLevel,
message: String,
eventType: String? = nil,
instanceID: UUID? = nil,
instanceName: String? = nil,
occurredAt: Date,
code: String? = nil,
durationMs: Int? = nil
) {
self.id = id
self.source = source
self.level = level
self.message = message
self.eventType = eventType
self.instanceID = instanceID
self.instanceName = instanceName
self.occurredAt = occurredAt
self.code = code
self.durationMs = durationMs
}
}
public struct ConnectionLogEntry: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let instanceID: UUID
public let instanceName: String
public let outcome: ConnectionOutcome
public let state: String
public let errorCode: String?
public let httpStatus: Int?
public let durationMs: Int
public let observedAt: Date
public init(
id: String,
instanceID: UUID,
instanceName: String,
outcome: ConnectionOutcome,
state: String,
errorCode: String? = nil,
httpStatus: Int? = nil,
durationMs: Int,
observedAt: Date
) {
self.id = id
self.instanceID = instanceID
self.instanceName = instanceName
self.outcome = outcome
self.state = state
self.errorCode = errorCode
self.httpStatus = httpStatus
self.durationMs = durationMs
self.observedAt = observedAt
}
}
public struct LogCapabilitySummary: Equatable, Sendable {
public let total: Int
public let supported: Int
public let unsupported: Int
public let authRequired: Int
public let degraded: Int
public let unknown: Int
public init(
total: Int,
supported: Int,
unsupported: Int,
authRequired: Int,
degraded: Int,
unknown: Int
) {
self.total = total
self.supported = supported
self.unsupported = unsupported
self.authRequired = authRequired
self.degraded = degraded
self.unknown = unknown
}
}
public struct ConnectionSummary: Equatable, Sendable {
public let total: Int
public let success: Int
public let failed: Int
public let averageDurationMs: Int
public let availabilityPercent: Int
}
public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
public let id: UUID
public let name: String
public let origin: String
public let tags: [String]
public let state: ConnectionStatus
public let healthStatus: String?
public let version: String?
public let platform: String?
public let observedAt: Date
public let featureStatus: [FeatureStatus]
public let connection: ConnectionSummary?
public init(
id: UUID,
name: String,
origin: String,
tags: [String],
state: ConnectionStatus,
healthStatus: String?,
version: String?,
platform: String?,
observedAt: Date,
featureStatus: [FeatureStatus],
connection: ConnectionSummary?
) {
self.id = id
self.name = name
self.origin = origin
self.tags = tags
self.state = state
self.healthStatus = healthStatus
self.version = version
self.platform = platform
self.observedAt = observedAt
self.featureStatus = featureStatus
self.connection = connection
}
}
public struct FeatureStatus: Equatable, Identifiable, Sendable {
public enum State: Equatable, Sendable {
case ready
case requiresLogin
case unavailable
}
public var id: String { name }
public let name: String
public let state: State
public init(name: String, state: State) {
self.name = name
self.state = state
}
}
public struct LogSnapshot: Equatable, Sendable {
public let observedAt: Date
public let runtime: [RuntimeLogEntry]
public let counts: [RuntimeLogSource: Int]
public let connections: [ConnectionLogEntry]
public let diagnostics: [InstanceDiagnostic]
}
public protocol LogStore: Sendable {
func runtimeLogs() async throws -> [RuntimeLogEntry]
func connectionLogs() async throws -> [ConnectionLogEntry]
func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws
func pruneConnections(before: Date) async throws -> Int
func clearRuntimeLogs() async throws -> Int
}
@@ -0,0 +1,266 @@
import Foundation
public struct LogCenterService: Sendable {
private struct ProbeOutcome: Sendable {
var status: ConnectionStatus
var outcome: ConnectionOutcome
var state: String
var errorCode: String?
var httpStatus: Int?
var durationMs: Int
var message: String
var version: String?
var platform: String?
}
private struct InstanceCollection: Sendable {
var runtime: [RuntimeLogEntry] = []
var connections: [ConnectionLogEntry] = []
var diagnostic: InstanceDiagnostic?
}
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.init(
transport: DirectTransport(client: client, sessions: sessions, credentials: credentials),
store: store
)
}
public func refresh(_ instances: [Instance]) async throws -> LogSnapshot {
var collected: [InstanceCollection] = []
await withTaskGroup(of: InstanceCollection.self) { group in
for instance in instances {
group.addTask { await self.collect(instance) }
}
for await value in group {
collected.append(value)
}
}
collected.sort { ($0.diagnostic?.name ?? "") < ($1.diagnostic?.name ?? "") }
let runtime = collected.flatMap(\.runtime).sorted { $0.occurredAt > $1.occurredAt }
let connections = collected.flatMap(\.connections).sorted { $0.observedAt > $1.observedAt }
let diagnostics = collected.compactMap(\.diagnostic)
try await store.append(runtime: runtime, connections: connections)
var counts: [RuntimeLogSource: Int] = [:]
for source in RuntimeLogSource.allCases {
counts[source] = runtime.filter { $0.source == source }.count
}
return LogSnapshot(
observedAt: Date(),
runtime: runtime,
counts: counts,
connections: connections,
diagnostics: diagnostics
)
}
private func collect(_ instance: Instance) async -> InstanceCollection {
var result = InstanceCollection()
let probe = await probe(instance)
var modules: [String: Result<Bool, Error>] = [
"connection": .success(true),
"device": await moduleResult(instance, .device)
]
if probe.outcome == .failed {
modules["connection"] = .failure(UpstreamError.notSupported)
modules["device"] = .failure(UpstreamError.notSupported)
}
result.connections.append(ConnectionLogEntry(
id: "probe-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
instanceID: instance.id,
instanceName: instance.name,
outcome: probe.outcome,
state: probe.state,
errorCode: probe.errorCode,
httpStatus: probe.httpStatus,
durationMs: probe.durationMs,
observedAt: Date()
))
if probe.outcome == .failed || probe.outcome == .stale {
result.runtime.append(RuntimeLogEntry(
id: "connection-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
source: .delivery,
level: probe.outcome == .failed ? .error : .warning,
message: probe.message,
instanceID: instance.id,
instanceName: instance.name,
occurredAt: Date(),
code: probe.errorCode,
durationMs: probe.durationMs
))
}
modules["status"] = .success(true)
result.diagnostic = diagnostic(
instance,
probe: probe,
modules: modules,
connections: result.connections
)
return result
}
private func probe(_ instance: Instance) async -> ProbeOutcome {
let startedAt = Date()
do {
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(
status: .online,
outcome: .success,
state: "online",
errorCode: nil,
httpStatus: response.status,
durationMs: duration,
message: "节点连接成功",
version: Self.text(root["version"]),
platform: Self.text(root["platform"])
)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
return ProbeOutcome(
status: .authRequired,
outcome: .stale,
state: "auth_required",
errorCode: "UNAUTHORIZED",
httpStatus: 401,
durationMs: elapsed(startedAt),
message: "节点需要登录",
version: nil,
platform: nil
)
} catch UpstreamError.timeout {
return ProbeOutcome(
status: .offline,
outcome: .failed,
state: "unreachable",
errorCode: "TIMEOUT",
httpStatus: nil,
durationMs: elapsed(startedAt),
message: "节点连接超时",
version: nil,
platform: nil
)
} catch {
return ProbeOutcome(
status: .degraded,
outcome: .failed,
state: "unreachable",
errorCode: "TRANSPORT",
httpStatus: nil,
durationMs: elapsed(startedAt),
message: "节点连接失败",
version: nil,
platform: nil
)
}
}
private func moduleResult(
_ instance: Instance,
_ route: UpstreamRoute
) async -> Result<Bool, Error> {
do {
_ = try await request(route, instance)
return .success(true)
} catch {
return .failure(error)
}
}
private func diagnostic(
_ instance: Instance,
probe: ProbeOutcome,
modules: [String: Result<Bool, Error>],
connections: [ConnectionLogEntry]
) -> InstanceDiagnostic {
let featureStatus = modules
.sorted { $0.key < $1.key }
.map { key, result -> FeatureStatus in
let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备"
switch result {
case .success:
return FeatureStatus(name: name, state: .ready)
case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden):
return FeatureStatus(name: name, state: .requiresLogin)
case .failure:
return FeatureStatus(name: name, state: .unavailable)
}
}
let success = connections.filter { $0.outcome == .success }.count
let failed = connections.filter { $0.outcome == .failed }.count
let average = connections.isEmpty
? 0
: connections.map(\.durationMs).reduce(0, +) / connections.count
return InstanceDiagnostic(
id: instance.id,
name: instance.name,
origin: instance.endpoint.displayAddress,
tags: instance.tags,
state: probe.status,
healthStatus: Self.healthLabel(probe),
version: probe.version,
platform: probe.platform,
observedAt: Date(),
featureStatus: featureStatus,
connection: ConnectionSummary(
total: connections.count,
success: success,
failed: failed,
averageDurationMs: average,
availabilityPercent: connections.isEmpty ? 0 : success * 100 / connections.count
)
)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
private func elapsed(_ startedAt: Date) -> Int {
max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
}
private static func healthLabel(_ probe: ProbeOutcome) -> String {
switch probe.state {
case "online", "UP", "ok": return "在线"
case "auth_required": return "需登录"
case "unreachable": return probe.errorCode == "TIMEOUT" ? "超时" : "不可达"
default: return probe.state
}
}
private static func text(_ value: Any?) -> String? {
guard let value = value as? String, !value.isEmpty, value.count <= 512 else { return nil }
return value
}
private static func date(_ value: Any?) -> Date {
guard let text = value as? String else { return Date() }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let parsed = formatter.date(from: text) { return parsed }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: text) ?? Date()
}
}
@@ -0,0 +1,313 @@
import SwiftUI
struct LogsView: View {
private enum Tab: Hashable {
case runtime
case connections
case diagnostics
}
@StateObject private var model: LogsViewModel
private let autoRefreshOnAppear: Bool
@State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
init(model: LogsViewModel? = nil) {
let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false)
_model = StateObject(wrappedValue: resolvedModel)
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
}
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
/// 空列表配一排筛选器只是噪音,有内容才给筛选入口。
private var hasRowsForTab: Bool {
switch tab {
case .runtime: return model.filteredRuntime.isEmpty == false
case .connections: return model.filteredConnections.isEmpty == false
case .diagnostics: return model.filteredDiagnostics.isEmpty == false
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if isUnavailable {
AICard {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取日志",
message: model.errorMessage ?? "所有节点都没有返回日志数据。",
actionTitle: "重试"
) {
Task { await model.refresh() }
}
}
} else if model.snapshot == nil && model.isLoading {
AILoadingCard("正在同步日志数据")
} else {
stats
AISegmentedControl(
"日志视图",
selection: $tab,
options: [
(value: Tab.runtime, title: "运行日志"),
(value: Tab.connections, title: "连接日志"),
(value: Tab.diagnostics, title: "设备诊断"),
]
)
if hasRowsForTab {
filterBar
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"清理 7 天前的连接日志?操作不可恢复。",
isPresented: $showsPruneDialog,
titleVisibility: .visible
) {
Button("清理连接日志", role: .destructive) {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
if model.snapshot == nil && autoRefreshOnAppear {
await model.refresh()
}
}
}
/// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。
private var stats: some View {
let runtime = model.snapshot?.runtime.count ?? 0
let connections = model.snapshot?.connections.count ?? 0
let diagnostics = model.snapshot?.diagnostics.count ?? 0
return Group {
if runtime + connections + diagnostics > 0 {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(
title: "运行日志", value: "\(runtime)", systemImage: "list.bullet", tint: AITheme.primary)
AIStatCard(
title: "连接探测", value: "\(connections)", systemImage: "dot.radiowaves.left.and.right",
tint: model.offlineCount == 0 ? AITheme.success : AITheme.warning)
AIStatCard(
title: "设备诊断", value: "\(diagnostics)", systemImage: "iphone.gen3", tint: AITheme.ink)
}
}
}
}
private var filterBar: some View {
VStack(spacing: 8) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText, minHeight: AITheme.compactControlHeight)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
if tab == .runtime {
HStack(spacing: 8) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
}
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
}
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
private var runtimeSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
AICard {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。")
}
} else {
ForEach(model.filteredRuntime) { entry in
AIListCard(
title: entry.message,
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
status: entry.level.label,
statusTint: levelTint(entry.level),
details: {
var values = [entry.source.label]
if let duration = entry.durationMs {
values.append("耗时 \(duration) ms")
}
return values
}()
)
}
}
}
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"连接探测记录",
systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条",
actionTitle: "清理",
actionSystemImage: "trash",
actionKind: .danger
) {
showsPruneDialog = true
}
if model.filteredConnections.isEmpty {
AICard {
AIEmptyState(
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
)
}
} else {
ForEach(model.filteredConnections) { entry in
AIListCard(
title: entry.instanceName,
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
status: entry.outcome.label,
statusTint: outcomeTint(entry.outcome),
details: [
"HTTP \(entry.httpStatus.map(String.init) ?? "—") · \(entry.durationMs) ms",
entry.errorCode ?? "无错误码",
]
)
}
}
}
}
private var diagnosticSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty {
AICard {
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
}
} else {
ForEach(model.filteredDiagnostics) { diagnostic in
AIListCard(
title: diagnostic.name,
status: diagnostic.healthStatus ?? "未知",
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
details: [
diagnostic.origin,
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
]
) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
alignment: .leading,
spacing: 6
) {
ForEach(diagnostic.featureStatus) { feature in
AITag(feature.name, tint: featureTint(feature.state))
}
if let connection = diagnostic.connection, connection.total > 1 {
AITag(
"延迟 \(connection.averageDurationMs) ms",
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
)
}
}
}
}
}
}
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level {
case .info: return AITheme.success
case .warning: return AITheme.warning
case .error: return AITheme.danger
}
}
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
switch outcome {
case .success: return AITheme.success
case .stale: return AITheme.warning
case .failed: return AITheme.danger
case .unsupported: return AITheme.muted
}
}
private func featureTint(_ state: FeatureStatus.State) -> Color {
switch state {
case .ready: return AITheme.success
case .requiresLogin: return AITheme.warning
case .unavailable: return AITheme.muted
}
}
}
@@ -0,0 +1,131 @@
import Foundation
@MainActor
final class LogsViewModel: ObservableObject {
@Published private(set) var snapshot: LogSnapshot?
@Published private(set) var instances: [Instance] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published var selectedSource: RuntimeLogSource?
@Published var selectedLevel: RuntimeLogLevel?
@Published var selectedOutcome: ConnectionOutcome?
@Published var selectedInstanceID: UUID?
@Published var searchText = ""
private let repository: any InstanceRepository
private let service: LogCenterService
private let store: any LogStore
let autoRefreshOnAppear: Bool
init(
repository: any InstanceRepository,
service: LogCenterService,
store: any LogStore,
autoRefreshOnAppear: Bool = true
) {
self.repository = repository
self.service = service
self.store = store
self.autoRefreshOnAppear = autoRefreshOnAppear
}
var filteredRuntime: [RuntimeLogEntry] {
guard let snapshot else { return [] }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return snapshot.runtime.filter { entry in
(selectedSource == nil || entry.source == selectedSource)
&& (selectedLevel == nil || entry.level == selectedLevel)
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
&& (query.isEmpty || matches(entry.message, query)
|| matches(entry.code, query)
|| matches(entry.eventType, query)
|| matches(entry.instanceName, query))
}
}
var filteredConnections: [ConnectionLogEntry] {
guard let snapshot else { return [] }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return snapshot.connections.filter { entry in
(selectedOutcome == nil || entry.outcome == selectedOutcome)
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
&& (query.isEmpty || matches(entry.errorCode, query)
|| matches(entry.instanceName, query))
}
}
var filteredDiagnostics: [InstanceDiagnostic] {
guard let snapshot else { return [] }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !query.isEmpty else { return snapshot.diagnostics }
return snapshot.diagnostics.filter { diagnostic in
matches(diagnostic.name, query)
|| matches(diagnostic.origin, query)
|| diagnostic.tags.contains { matches($0, query) }
}
}
var offlineCount: Int {
snapshot?.diagnostics.filter { $0.state == .offline }.count ?? 0
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
let fetched = try await service.refresh(loaded)
let storedRuntime = try await store.runtimeLogs()
let storedConnections = try await store.connectionLogs()
var runtime = fetched.runtime
for entry in storedRuntime where !runtime.contains(where: { $0.id == entry.id }) {
runtime.append(entry)
}
runtime.sort { $0.occurredAt > $1.occurredAt }
var connections = fetched.connections
for entry in storedConnections where !connections.contains(where: { $0.id == entry.id }) {
connections.append(entry)
}
connections.sort { $0.observedAt > $1.observedAt }
snapshot = LogSnapshot(
observedAt: fetched.observedAt,
runtime: Array(runtime.prefix(500)),
counts: Self.recount(runtime),
connections: Array(connections.prefix(200)),
diagnostics: fetched.diagnostics
)
} catch {
errorMessage = "无法加载日志中心。"
}
isLoading = false
}
func pruneConnections(olderThanDays days: Int) async -> Int {
let cutoff = Date().addingTimeInterval(TimeInterval(-days * 24 * 60 * 60))
do {
let removed = try await store.pruneConnections(before: cutoff)
if removed > 0 {
await refresh()
}
return removed
} catch {
errorMessage = "清理连接日志失败。"
return 0
}
}
private func matches(_ value: String?, _ query: String) -> Bool {
value?.localizedLowercase.contains(query) == true
}
private static func recount(_ runtime: [RuntimeLogEntry]) -> [RuntimeLogSource: Int] {
var counts: [RuntimeLogSource: Int] = [:]
for source in RuntimeLogSource.allCases {
counts[source] = runtime.filter { $0.source == source }.count
}
return counts
}
}
@@ -0,0 +1,74 @@
import Foundation
public enum SMSDirection: String, Equatable, Sendable {
case incoming
case outgoing
case unknown
public var label: String {
switch self {
case .incoming: return "收件"
case .outgoing: return "发件"
case .unknown: return "未知"
}
}
}
public struct SMSMessage: Equatable, Identifiable, Sendable {
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
public let upstreamID: String
public let instanceID: UUID
public let instanceName: String
public let phoneNumber: String
public let content: String
public let direction: SMSDirection
public let status: String
public let timestamp: String
public var id: String { "\(instanceID.uuidString)#\(upstreamID)" }
/// 上游 status 是英文原词,列表里直接展示会混进一行英文;未知值仍然原样显示,不做假结论。
public var statusLabel: String? {
let key = status.trimmingCharacters(in: .whitespaces).lowercased()
guard key.isEmpty == false else { return nil }
switch key {
case "received", "in", "incoming": return "已接收"
case "sent": return "已发送"
case "delivered": return "已送达"
case "read": return "已读"
case "failed", "error": return "发送失败"
case "pending", "queued", "sending": return "发送中"
default: return status
}
}
/// 收件的"已接收"和方向标签说的是同一件事,只有发件状态才值得再占一个位置。
public var deliveryLabel: String? {
guard direction == .outgoing else { return nil }
return statusLabel
}
public init(
upstreamID: String,
instanceID: UUID,
instanceName: String,
phoneNumber: String,
content: String,
direction: SMSDirection,
status: String,
timestamp: String
) {
self.upstreamID = upstreamID
self.instanceID = instanceID
self.instanceName = instanceName
self.phoneNumber = phoneNumber
self.content = content
self.direction = direction
self.status = status
self.timestamp = timestamp
}
}
public struct MessagePage: Equatable, Sendable {
public let messages: [SMSMessage]
public let total: Int
}
@@ -0,0 +1,124 @@
import Foundation
public enum MessageServiceError: Error, Equatable {
case invalidInput
case invalidResponse
case unauthorized
}
public struct MessageService: Sendable {
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.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
}
public func messages(
_ instances: [Instance],
limit: Int = 50,
direction: SMSDirection? = nil
) async throws -> MessagePage {
let directionText: String?
switch direction {
case .incoming: directionText = "incoming"
case .outgoing: directionText = "outgoing"
default: directionText = nil
}
var collected: [SMSMessage] = []
var total = 0
var lastError: Error?
for instance in instances {
do {
let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance)
let page = try Self.parsePage(response, instance)
collected.append(contentsOf: page.messages)
total += page.total
} catch {
lastError = error
}
}
if collected.isEmpty && total == 0, let lastError {
throw lastError
}
collected.sort { $0.timestamp > $1.timestamp }
return MessagePage(messages: collected, total: total)
}
public func send(
_ instance: Instance,
phoneNumber rawPhoneNumber: String,
content rawContent: String
) async throws {
let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phoneNumber.isEmpty, !content.isEmpty else {
throw MessageServiceError.invalidInput
}
_ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance)
}
public func delete(_ ids: [String], from instance: Instance) async throws {
let numericIDs = ids.compactMap(Int.init)
guard !numericIDs.isEmpty else {
throw MessageServiceError.invalidInput
}
_ = try await request(.smsBatchDelete(ids: numericIDs), instance)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
let root = try JSONSerialization.jsonObject(with: response.data)
guard let container = Self.dataContainer(root),
let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any]
else {
throw MessageServiceError.invalidResponse
}
let rawMessages = (data["messages"] as? [Any]) ?? []
let messages = rawMessages.compactMap { Self.message($0, instance) }
let total = (data["total"] as? Int) ?? messages.count
return MessagePage(messages: messages, total: total)
}
private static func dataContainer(_ root: Any) -> [String: Any]? {
root as? [String: Any]
}
private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? {
guard let value = raw as? [String: Any] else { return nil }
let id: String
if let number = value["id"] as? Int {
id = String(number)
} else if let text = value["id"] as? String {
id = text
} else {
return nil
}
let directionText = (value["direction"] as? String)?.lowercased() ?? ""
let direction: SMSDirection = directionText == "incoming" ? .incoming
: directionText == "outgoing" ? .outgoing
: .unknown
return SMSMessage(
upstreamID: id,
instanceID: instance.id,
instanceName: instance.name,
phoneNumber: value["phone_number"] as? String ?? "",
content: value["content"] as? String ?? "",
direction: direction,
status: value["status"] as? String ?? "",
timestamp: value["timestamp"] as? String ?? ""
)
}
}
@@ -0,0 +1,189 @@
import SwiftUI
struct MessagesView: View {
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) {
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
// 所以调试开关必须先落到这个具体的 model 对象。
let target = model ?? DefaultContainer.makeMessagesModel()
#if DEBUG
if DebugLaunch.has("--sheet-send") {
target.showingSendForm = true
}
#endif
_model = StateObject(wrappedValue: target)
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
} else if model.filteredRows.isEmpty {
AICard {
if let errorMessage = model.errorMessage {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取消息",
message: errorMessage,
actionTitle: "重试"
) {
Task { await model.refresh() }
}
} else {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "所有节点都没有返回短信。"
)
}
}
} else {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.toolbar {
// 发送入口和节点页的「添加实例」保持一致:放在导航栏右侧,
// 底部固定条在 TabView 里会被自定义标签栏盖住,之前整条 CTA 是不可达的。
ToolbarItem(placement: .primaryAction) {
HStack(spacing: 8) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
AIIconButton("发送消息", systemImage: "paperplane") {
model.showingSendForm = true
}
}
}
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
}
}
private var filterBar: some View {
VStack(spacing: 8) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
// 选择器右侧原本是一整片空白,这里补上当前条数,让这一行读完就知道结果规模。
.overlay(alignment: .trailing) {
Text("\(model.filteredRows.count) 条")
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
.padding(.trailing, 12)
.accessibilityHidden(true)
}
AISearchField("搜索号码或内容", text: $model.searchText, minHeight: AITheme.compactControlHeight)
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
AITag(
message.direction.label,
systemImage: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
Spacer(minLength: 8)
Text(TimestampText.short(message.timestamp))
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.faint)
.lineLimit(1)
.accessibilityLabel("时间 \(message.timestamp)")
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
// 删除按钮的点击区是 44pt,留在行内会把这一行撑到 44 高,正文与落款之间
// 因此凭空多出一条约 22pt 的空带。这里让落款按自身高度排,点击区挂在
// 行尾并垂直居中,多出的 12pt 落进卡片内边距,正文右缘留出等宽避让。
HStack(alignment: .center, spacing: 8) {
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption)
.labelStyle(.titleAndIcon)
.imageScale(.small)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
if let delivery = message.deliveryLabel {
Text(delivery)
.font(.caption2.weight(.medium))
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
.lineLimit(1)
}
}
.padding(.trailing, 44)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .trailing) {
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger, quiet: true) {
pendingDeleteMessage = message
}
// 图标在 44pt 点击区里居中,右移半个内边距让图标右缘与正文右缘对齐。
.offset(x: 12)
}
}
}
}
}
@@ -0,0 +1,92 @@
import Foundation
@MainActor
final class MessagesViewModel: ObservableObject {
enum DirectionFilter: String, CaseIterable, Identifiable {
case all = "全部"
case incoming = "收件"
case outgoing = "发件"
var id: String { rawValue }
}
@Published var rows: [SMSMessage] = []
@Published var instances: [Instance] = []
@Published var selectedInstanceID: UUID?
@Published var searchText = ""
@Published var direction: DirectionFilter = .all
@Published var isLoading = false
@Published var errorMessage: String?
@Published var showingSendForm = false
private let repository: any InstanceRepository
private let service: MessageService
init(repository: any InstanceRepository, service: MessageService) {
self.repository = repository
self.service = service
}
var filteredRows: [SMSMessage] {
rows.filter { message in
selectedInstanceID == nil || message.instanceID == selectedInstanceID
}.filter { message in
switch direction {
case .all: return true
case .incoming: return message.direction == .incoming
case .outgoing: return message.direction == .outgoing
}
}.filter { message in
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return true }
return message.phoneNumber.localizedCaseInsensitiveContains(query)
|| message.content.localizedCaseInsensitiveContains(query)
|| message.instanceName.localizedCaseInsensitiveContains(query)
}
}
var selectedInstance: Instance? {
instances.first { $0.id == selectedInstanceID }
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
if selectedInstanceID == nil || !loaded.contains(where: { $0.id == selectedInstanceID }) {
selectedInstanceID = nil
}
let page = try await service.messages(loaded, limit: 100)
rows = page.messages
} catch MessageServiceError.unauthorized, UpstreamError.unauthorized {
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
} catch let error as UpstreamError {
// 中继模式下失败原因有四类以上,各自对应不同处理动作;
// 统一兜底文案会把"重试即可"和"需要重新配对"说成同一件事。
errorMessage = ErrorMessage.describe(error, nodeName: selectedInstance?.name ?? "节点")
} catch {
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
}
isLoading = false
}
func send(phoneNumber: String, content: String) async throws {
guard let instance = selectedInstance ?? instances.first else {
throw MessageServiceError.invalidInput
}
try await service.send(instance, phoneNumber: phoneNumber, content: content)
await refresh()
}
func delete(_ message: SMSMessage) async {
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
do {
try await service.delete([message.id], from: instance)
rows.removeAll { $0.id == message.id }
} catch {
errorMessage = "删除消息失败。"
}
}
}
@@ -0,0 +1,126 @@
import SwiftUI
struct SendMessageFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
@State private var isSending = false
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!model.instances.isEmpty && !trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("实例")
Picker(
"实例",
selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)
) {
if model.instances.isEmpty {
Text("暂无实例").tag(Optional<UUID>.none)
}
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("收件号码")
.submitLabel(.next)
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("消息内容")
.submitLabel(.send)
.onSubmit {
if canSend { send() }
}
.lineLimit(4...8)
}
}
}
if let validationMessage {
AIStatusBanner(
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
send()
} label: {
HStack(spacing: 8) {
if isSending {
ProgressView()
.controlSize(.small)
}
Text(isSending ? "发送中" : "发送")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend || isSending)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("发送消息")
#if os(iOS)
// 与「添加实例」一致:表行内标题,避免大标题在表单上方留出一整条空白。
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
private func send() {
guard !isSending else { return }
isSending = true
Task {
do {
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
}
isSending = false
}
}
}
@@ -0,0 +1,208 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var model: SettingsViewModel
@ObservedObject private var themeStore = ThemeStore.shared
@State private var showsClearDialog = false
init(model: SettingsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel())
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let notice = model.notice {
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
themeCard
runtimeCard
storageCard
securityCard
dangerZoneCard
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("设置")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.refreshable {
await model.refresh()
}
.confirmationDialog(
"确认清除运行日志?此操作不可恢复。",
isPresented: $showsClearDialog,
titleVisibility: .visible
) {
Button("清除运行日志", role: .destructive) {
Task { await model.clearRuntimeLogs() }
}
Button("取消", role: .cancel) {}
}
.task {
await model.refresh()
}
}
private var runtimeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
AIKeyValueRow("版本", value: runtime.marketingVersion)
// 构建号单独一行:同一营销版本会连续出多个包,只写版本号分不清装的是哪个。
AIKeyValueRow("构建", value: runtime.buildVersion)
AIKeyValueRow("系统", value: runtime.systemVersion)
} else {
Text("正在读取应用信息…")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
}
}
}
private var themeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
AISectionTitle("主题外观", systemImage: "circle.lefthalf.filled")
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
GridRow {
ForEach(ThemeMode.allCases) { mode in
themeOption(mode)
}
}
}
}
}
}
private func themeOption(_ mode: ThemeMode) -> some View {
let isSelected = themeStore.mode == mode
return Button {
themeStore.set(mode)
} label: {
VStack(spacing: 7) {
Image(systemName: mode.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.accessibilityHidden(true)
Text(mode.label)
.font(.caption.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, minHeight: 68)
// 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。
.background(isSelected ? AITheme.primary : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(isSelected ? Color.clear : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("主题:\(mode.label)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
private var storageCard: some View {
AICard {
NavigationLink {
LogsView()
} label: {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
AISectionTitle("本地数据", systemImage: "externaldrive")
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
}
AIKeyValueRow("日志记录", value: model.storageLabel)
}
}
.buttonStyle(.plain)
.accessibilityHint("进入日志与诊断")
}
}
private var securityCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("隐私与数据", systemImage: "lock.shield")
AIKeyValueRow("节点与凭据", value: "仅写入本机存储")
AIKeyValueRow("网络请求", value: "只访问你添加的节点")
// 卡内再套一个描边框会变成"卡中卡",这里退成一段脚注。
HStack(alignment: .top, spacing: 7) {
Image(systemName: "checkmark.seal")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AITheme.success)
.padding(.top, 2)
.accessibilityHidden(true)
Text("不经过中转服务器,也不上传遥测数据;节点地址、密码与日志只保存在这台 iPhone 上,删除应用即彻底清除。")
.font(.caption)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 2)
}
}
}
private var dangerZoneCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
Button {
showsClearDialog = true
} label: {
HStack(spacing: 8) {
Image(systemName: "trash")
.font(.system(size: 14, weight: .semibold))
.accessibilityHidden(true)
Text("清除运行日志")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.opacity(0.7)
.accessibilityHidden(true)
}
.foregroundStyle(AITheme.danger)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.compactControlHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AITheme.danger.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(AITheme.danger.opacity(0.20))
}
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
}
@@ -0,0 +1,79 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
struct SettingsRuntime: Equatable {
let appName: String
let marketingVersion: String
let buildVersion: String
let platform: String
let systemVersion: String
}
@MainActor
final class SettingsViewModel: ObservableObject {
@Published private(set) var runtime: SettingsRuntime?
@Published private(set) var isLoading = false
@Published var storageLabel = "—"
@Published var notice: String?
@Published var errorMessage: String?
private let store: any LogStore
init(store: any LogStore) {
self.store = store
}
func refresh() async {
isLoading = true
errorMessage = nil
runtime = SettingsRuntime(
appName: AppMetadata.name,
marketingVersion: AppMetadata.marketingVersion,
buildVersion: AppMetadata.buildVersion,
platform: "iOS",
systemVersion: Self.systemVersion()
)
await loadStorageLabel()
isLoading = false
}
func clearRuntimeLogs() async -> Int {
do {
let removed = try await store.clearRuntimeLogs()
notice = removed > 0 ? "已清除 \(removed) 条运行日志。" : "没有可清除的运行日志。"
errorMessage = nil
await loadStorageLabel()
return removed
} catch {
notice = nil
errorMessage = "清除运行日志失败。"
return 0
}
}
private func loadStorageLabel() async {
do {
let runtimeCount = try await store.runtimeLogs().count
let connectionCount = try await store.connectionLogs().count
storageLabel = "\(runtimeCount) 条运行日志 · \(connectionCount) 条连接日志"
} catch {
storageLabel = "—"
errorMessage = "无法读取本地数据状态。"
}
}
/// 只报对用户有意义的系统版本,不暴露机器标识之类的原始信息。
private static func systemVersion() -> String {
#if canImport(UIKit)
let device = UIDevice.current
return "\(device.systemName) \(device.systemVersion)"
#else
let version = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(version.majorVersion).\(version.minorVersion)"
#endif
}
}
@@ -0,0 +1,185 @@
import Foundation
public enum RepositoryError: Error, Equatable, Sendable {
case invalidOrigin
case duplicateOrigin
case revisionConflict
case notFound
case databaseFailed
}
public struct Instance: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var name: String
/// Where this node is reached from. Direct on the local network, or through a relay.
///
/// This replaced a non-optional `origin: URL`. The migration in `init(from:)` reads the old
/// shape so an existing node list upgrades without being reported as duplicate, and every write
/// emits only the new shape.
public var endpoint: InstanceEndpoint
public var tags: [String]
public var credentialConfigured: Bool
public var configRevision: Int
public let createdAt: Date
public var updatedAt: Date
public init(
id: UUID,
name: String,
endpoint: InstanceEndpoint,
tags: [String] = [],
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.name = name
self.endpoint = endpoint
self.tags = tags
self.credentialConfigured = credentialConfigured
self.configRevision = configRevision
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convenience for the direct case, which is what most call sites mean.
public init(
id: UUID,
name: String,
origin: URL,
tags: [String] = [],
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
updatedAt: Date
) {
self.init(
id: id,
name: name,
endpoint: .direct(origin),
tags: tags,
credentialConfigured: credentialConfigured,
configRevision: configRevision,
createdAt: createdAt,
updatedAt: updatedAt
)
}
/// The direct origin, when this instance has one.
public var origin: URL? { endpoint.directOrigin }
public var isRelay: Bool { endpoint.isRelay }
private enum CodingKeys: String, CodingKey {
case id, name, endpoint, origin, tags, credentialConfigured, configRevision, createdAt, updatedAt
}
/// Decodes the new shape, falling back to the pre-relay `origin` field.
///
/// The fallback is what makes the upgrade invisible: an app that dropped support for the old key
/// would fail to decode the whole store and lose every node. Note that `origin` is decoded
/// leniently as a string and parsed here rather than decoded as a `URL`, because the encoder
/// that wrote it may have produced a spelling `URL` will not round-trip from a bare string in
/// every case.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
if let endpoint = try container.decodeIfPresent(InstanceEndpoint.self, forKey: .endpoint) {
self.endpoint = endpoint
} else if let origin = try container.decodeIfPresent(URL.self, forKey: .origin) {
self.endpoint = .direct(origin)
} else {
throw DecodingError.keyNotFound(
CodingKeys.endpoint,
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "an instance must carry either endpoint or the legacy origin"
)
)
}
tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
credentialConfigured = try container.decodeIfPresent(Bool.self, forKey: .credentialConfigured) ?? false
configRevision = try container.decodeIfPresent(Int.self, forKey: .configRevision) ?? 1
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(endpoint, forKey: .endpoint)
try container.encode(tags, forKey: .tags)
try container.encode(credentialConfigured, forKey: .credentialConfigured)
try container.encode(configRevision, forKey: .configRevision)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
}
}
public struct InstanceInput: Sendable {
public var name: String
public var endpoint: InstanceEndpoint
public var tags: [String]
public var password: String?
public init(
name: String,
endpoint: InstanceEndpoint,
tags: [String] = [],
password: String? = nil
) {
self.name = name
self.endpoint = endpoint
self.tags = tags
self.password = password
}
public init(
name: String,
origin: URL,
tags: [String] = [],
password: String? = nil
) {
self.init(name: name, endpoint: .direct(origin), tags: tags, password: password)
}
}
public struct InstancePatch: Sendable {
public var name: String?
public var endpoint: InstanceEndpoint?
public var tags: [String]?
public var password: String?
public init(
name: String? = nil,
endpoint: InstanceEndpoint? = nil,
tags: [String]? = nil,
password: String? = nil
) {
self.name = name
self.endpoint = endpoint
self.tags = tags
self.password = password
}
}
public struct InstanceQuery: Sendable {
public var search: String?
public var tags: [String]?
public init(search: String? = nil, tags: [String]? = nil) {
self.search = search
self.tags = tags
}
}
public protocol InstanceRepository: Sendable {
func list(_ query: InstanceQuery) async throws -> [Instance]
func get(_ id: UUID) async throws -> Instance?
func create(_ input: InstanceInput) async throws -> Instance
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
func delete(_ id: UUID, expectedRevision: Int) async throws
}
@@ -0,0 +1,148 @@
import SwiftUI
@main
struct MultiSimAdminApp: App {
@StateObject private var themeStore = ThemeStore.shared
@State private var tab: AppTab
init() {
let arguments = ProcessInfo.processInfo.arguments
var initial: AppTab = .fleet
if arguments.contains("--tab-messages") {
initial = .messages
} else if arguments.contains("--tab-settings") {
initial = .settings
}
#if DEBUG
if arguments.contains("--tab-detail") {
initial = .debugDetail
}
if arguments.contains("--tab-logs") {
initial = .debugLogs
}
#endif
_tab = State(initialValue: initial)
}
var body: some Scene {
WindowGroup {
Group {
switch tab {
case .fleet:
NavigationStack { FleetView().aiInlineTitle() }
case .messages:
NavigationStack { MessagesView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
#if DEBUG
case .debugDetail:
NavigationStack { DebugDetailView().aiInlineTitle() }
case .debugLogs:
NavigationStack { LogsView().aiInlineTitle() }
#endif
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
AITabBar(selection: $tab)
}
.tint(AITheme.primary)
.preferredColorScheme(themeStore.mode.colorScheme)
#if os(iOS)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
#endif
}
}
}
#if os(iOS)
extension View {
fileprivate func aiInlineTitle() -> some View {
navigationBarTitleDisplayMode(.inline)
}
}
#else
extension View {
fileprivate func aiInlineTitle() -> some View { self }
}
#endif
enum AppTab: Hashable {
case fleet
case messages
case settings
#if DEBUG
case debugDetail
case debugLogs
#endif
}
#if DEBUG
/// 本地截图验证用的入口,Release 构建不包含。
struct DebugDetailView: View {
@State private var target: Instance?
var body: some View {
Group {
if let instance = target {
InstanceDetailView(instance: instance)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task {
let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json"))
guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return }
// 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。
target = listed.first { $0.endpoint.directOrigin?.port == 3000 } ?? listed[0]
}
}
}
#endif
struct AITabBar: View {
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
tab(.fleet, "节点", "server.rack")
tab(.messages, "消息", "message")
tab(.settings, "设置", "gearshape")
}
.frame(height: 62)
.background {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
Rectangle()
.fill(AITheme.chrome)
}
.ignoresSafeArea(edges: .bottom)
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View {
let isSelected = selection == value
return Button {
selection = value
} label: {
VStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
}
@@ -0,0 +1,214 @@
import Foundation
public struct FileInstanceRepository: InstanceRepository {
private struct Store: Codable {
var instances: [Instance] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository")
public init(location: URL) {
self.location = location
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}
public func list(_ query: InstanceQuery) async throws -> [Instance] {
try await run { store in
var instances = store.instances
if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines),
!search.isEmpty {
instances = instances.filter {
$0.name.localizedCaseInsensitiveContains(search)
|| $0.endpoint.displayAddress.localizedCaseInsensitiveContains(search)
}
}
if let tags = query.tags, !tags.isEmpty {
instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) }
}
return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
public func get(_ id: UUID) async throws -> Instance? {
try await run { $0.instances.first { $0.id == id } }
}
public func create(_ input: InstanceInput) async throws -> Instance {
try await run { store in
guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RepositoryError.invalidOrigin
}
let endpoint = try Self.normalized(input.endpoint)
guard !store.instances.contains(where: { $0.endpoint.dedupKey == endpoint.dedupKey }) else {
throw RepositoryError.duplicateOrigin
}
let now = Date()
let instance = Instance(
id: UUID(),
name: input.name.trimmingCharacters(in: .whitespacesAndNewlines),
endpoint: endpoint,
tags: Self.normalizedTags(input.tags),
credentialConfigured: !(input.password ?? "").isEmpty,
createdAt: now,
updatedAt: now
)
store.instances.append(instance)
return instance
}
}
public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
try await run { store in
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard store.instances[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
var instance = store.instances[index]
if let name = patch.name {
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin }
instance.name = normalized
}
if let endpoint = patch.endpoint {
let normalized = try Self.normalized(endpoint)
guard !store.instances.contains(where: {
$0.id != id && $0.endpoint.dedupKey == normalized.dedupKey
}) else {
throw RepositoryError.duplicateOrigin
}
instance.endpoint = normalized
}
if let tags = patch.tags {
instance.tags = Self.normalizedTags(tags)
}
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}
instance.updatedAt = Date()
instance.configRevision += 1
store.instances[index] = instance
return instance
}
}
public func delete(_ id: UUID, expectedRevision: Int) async throws {
try await run { store in
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard store.instances[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
store.instances.remove(at: index)
}
}
private func run<T: Sendable>(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var store = try self.readStore()
let output = try transform(&store)
try self.writeStore(store)
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func readStore() throws -> Store {
guard FileManager.default.fileExists(atPath: location.path) else {
return Store()
}
do {
return try decoder.decode(Store.self, from: Data(contentsOf: location))
} catch {
throw RepositoryError.databaseFailed
}
}
private func writeStore(_ store: Store) throws {
do {
let directory = location.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try encoder.encode(store).write(to: location, options: [.atomic])
} catch {
throw RepositoryError.databaseFailed
}
}
private static func normalized(_ endpoint: InstanceEndpoint) throws -> InstanceEndpoint {
switch endpoint {
case .direct(let url):
return .direct(try normalizedOrigin(url))
case .relay(let relay):
// A relay endpoint is validated on its own terms: https, a parseable node id, and the
// exact key sizes the envelope format requires. A half-configured relay entry that
// reached the transport would fail as an unreadable response rather than as "this
// pairing is incomplete".
guard relay.relayBaseURL.scheme == "https",
relay.relayBaseURL.host?.isEmpty == false,
relay.relayBaseURL.path.isEmpty || relay.relayBaseURL.path == "/"
else {
throw RepositoryError.invalidOrigin
}
guard relay.nodeID.hasPrefix("nd_"), relay.nodeID.count >= 11 else {
throw RepositoryError.invalidOrigin
}
guard relay.agentSigningKey.count == RelayWire.keyBytes,
relay.agentKxKey.count == RelayWire.keyBytes,
relay.deviceToken.count >= 16,
relay.deviceID.hasPrefix("ap_")
else {
throw RepositoryError.invalidOrigin
}
var normalized = relay
normalized.relayBaseURL = URL(string: relay.relayBaseURL.absoluteString.trimmingCharacters(
in: CharacterSet(charactersIn: "/")
)) ?? relay.relayBaseURL
return .relay(normalized)
}
}
private static func normalizedOrigin(_ url: URL) throws -> URL {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme == "http" || components.scheme == "https",
let host = components.host, !host.isEmpty
else {
throw RepositoryError.invalidOrigin
}
guard components.path.isEmpty || components.path == "/" else {
throw RepositoryError.invalidOrigin
}
var normalized = components
normalized.path = ""
normalized.query = nil
normalized.fragment = nil
guard let result = normalized.url else {
throw RepositoryError.invalidOrigin
}
return result
}
private static func normalizedTags(_ tags: [String]) -> [String] {
var seen = Set<String>()
return tags
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
}
}
@@ -0,0 +1,112 @@
import Foundation
public struct FileLogStore: LogStore {
private struct Store: Codable {
var runtime: [RuntimeLogEntry] = []
var connections: [ConnectionLogEntry] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.log-store")
public init(location: URL) {
self.location = location
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}
public func runtimeLogs() async throws -> [RuntimeLogEntry] {
try await run { $0.runtime.sorted { $0.occurredAt > $1.occurredAt } }
}
public func connectionLogs() async throws -> [ConnectionLogEntry] {
try await run { $0.connections.sorted { $0.observedAt > $1.observedAt } }
}
public func append(
runtime: [RuntimeLogEntry],
connections: [ConnectionLogEntry]
) async throws {
try await run { store in
var mergedRuntime = store.runtime
for entry in runtime where !mergedRuntime.contains(where: { $0.id == entry.id }) {
mergedRuntime.append(entry)
}
mergedRuntime.sort { $0.occurredAt > $1.occurredAt }
store.runtime = Array(mergedRuntime.prefix(2_000))
var mergedConnections = store.connections
for entry in connections
where !mergedConnections.contains(where: { $0.id == entry.id }) {
mergedConnections.append(entry)
}
mergedConnections.sort { $0.observedAt > $1.observedAt }
store.connections = Array(mergedConnections.prefix(20_000))
}
}
public func pruneConnections(before cutoff: Date) async throws -> Int {
try await run { store in
let retained = store.connections.filter { $0.observedAt >= cutoff }
let removed = store.connections.count - retained.count
store.connections = retained
return removed
}
}
public func clearRuntimeLogs() async throws -> Int {
try await run { store in
let removed = store.runtime.count
store.runtime = []
return removed
}
}
private func run<T: Sendable>(
_ transform: @escaping @Sendable (inout Store) throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var store = try self.readStore()
let result = try transform(&store)
try self.writeStore(store)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func readStore() throws -> Store {
guard FileManager.default.fileExists(atPath: location.path) else {
return Store()
}
do {
return try decoder.decode(Store.self, from: Data(contentsOf: location))
} catch {
return Store()
}
}
private func writeStore(_ store: Store) throws {
do {
try FileManager.default.createDirectory(
at: location.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try encoder.encode(store).write(to: location, options: [.atomic])
} catch {
throw RepositoryError.databaseFailed
}
}
}
@@ -0,0 +1,348 @@
import Foundation
/// The canonical JSON form this protocol hashes.
///
/// This is a hand-written parser rather than `JSONSerialization`, and the reason is not preference:
/// the canonical form is defined over the *spelling* of the input, and every Foundation decoder
/// erases spelling before the caller sees anything. `1.0` and `1` both arrive as `NSNumber(1)`;
/// a lone surrogate is either rejected outright or silently repaired depending on the framework
/// version. Trying to recover the original text from a decoded tree is how two languages end up
/// hashing different bytes for the same body, which surfaces as an intent mismatch the phone
/// reports as "confirmation failed" with no way to tell which side was wrong.
///
/// The rules, which the Go agent and the Node reference implementation implement identically, are
/// documented in `agent/internal/wire/json.go`. The short version: keys sorted by UTF-8 bytes,
/// no insignificant whitespace, integers only in canonical spelling, strings escaped as `\" \\ \n
/// \r \t \b \f`, `\u00XX` for other control characters, literal UTF-8 for everything else, and a
/// lone surrogate escape is an error rather than a replacement character.
public enum CanonicalJSON {
public enum Failure: Error, Equatable {
case invalidJSON
case trailingData
case nonCanonicalNumber(String)
case loneSurrogate(UInt32)
case unsupportedValue
}
/// Canonicalises a JSON document. An empty or whitespace-only body canonicalises to no bytes at
/// all, not to the four characters `null`, so "no body" and "empty body" cannot be confused.
public static func canonicalize(_ text: String) throws -> Data {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Data() }
var parser = Parser(Array(trimmed.unicodeScalars))
let value = try parser.parseValue()
parser.skipWhitespace()
guard parser.isAtEnd else { throw Failure.trailingData }
var out = Data()
try serialize(value, into: &out)
return out
}
/// Canonicalises raw body bytes, which is what the intent digest needs.
public static func canonicalize(_ data: Data) throws -> Data {
guard let text = String(data: data, encoding: .utf8) else { throw Failure.invalidJSON }
return try canonicalize(text)
}
// MARK: - Value tree
indirect enum Value {
case null
case bool(Bool)
case integer(String)
case string(String)
case array([Value])
case object([(key: String, value: Value)])
}
// MARK: - Serialisation
static func serialize(_ value: Value, into out: inout Data) throws {
switch value {
case .null:
out.append(contentsOf: Array("null".utf8))
case .bool(let flag):
out.append(contentsOf: Array((flag ? "true" : "false").utf8))
case .integer(let literal):
out.append(contentsOf: Array(literal.utf8))
case .string(let text):
escape(text, into: &out)
case .array(let items):
out.append(UInt8(ascii: "["))
for (index, item) in items.enumerated() {
if index > 0 { out.append(UInt8(ascii: ",")) }
try serialize(item, into: &out)
}
out.append(UInt8(ascii: "]"))
case .object(let members):
// UTF-8 byte order, which is what the Go agent's sort.Strings produces. Two shortcuts
// are wrong here and both look right on ASCII: Swift's default String `<` uses Unicode
// collation (so "é" sorts before "z"), and Foundation's `.sortedKeys` does the same.
let sorted = members.sorted { Array($0.key.utf8).lexicographicallyPrecedes(Array($1.key.utf8)) }
out.append(UInt8(ascii: "{"))
for (index, member) in sorted.enumerated() {
if index > 0 { out.append(UInt8(ascii: ",")) }
escape(member.key, into: &out)
out.append(UInt8(ascii: ":"))
try serialize(member.value, into: &out)
}
out.append(UInt8(ascii: "}"))
}
}
/// Writes a JSON string literal in the canonical spelling.
///
/// The two rules worth stating: control characters below 0x20 use the short forms JSON names
/// where they exist and `\u00XX` otherwise (matching Go's encoder with HTML escaping off), and
/// everything else — including U+2028, U+2029, DEL and astral characters — is emitted as
/// literal UTF-8. Go's encoder escapes U+2028/U+2029 even with HTML escaping disabled, which is
/// why the Go side had to grow its own writer too; the vectors pin the literal form.
static func escape(_ text: String, into out: inout Data) {
out.append(UInt8(ascii: "\""))
for scalar in text.unicodeScalars {
switch scalar {
case "\"":
out.append(contentsOf: Array("\\\"".utf8))
case "\\":
out.append(contentsOf: Array("\\\\".utf8))
case "\n":
out.append(contentsOf: Array("\\n".utf8))
case "\r":
out.append(contentsOf: Array("\\r".utf8))
case "\t":
out.append(contentsOf: Array("\\t".utf8))
case Unicode.Scalar(0x08):
out.append(contentsOf: Array("\\b".utf8))
case Unicode.Scalar(0x0C):
out.append(contentsOf: Array("\\f".utf8))
default:
if scalar.value < 0x20 {
let hex = String(format: "%04x", scalar.value)
out.append(contentsOf: Array("\\u\(hex)".utf8))
} else {
out.append(contentsOf: Array(String(scalar).utf8))
}
}
}
out.append(UInt8(ascii: "\""))
}
// MARK: - Parser
struct Parser {
private let scalars: [Unicode.Scalar]
private var index = 0
init(_ scalars: [Unicode.Scalar]) {
self.scalars = scalars
}
var isAtEnd: Bool { index >= scalars.count }
mutating func skipWhitespace() {
while index < scalars.count, isSpace(scalars[index]) { index += 1 }
}
private func isSpace(_ scalar: Unicode.Scalar) -> Bool {
scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
}
private func peek() -> Unicode.Scalar? {
index < scalars.count ? scalars[index] : nil
}
private mutating func expect(_ scalar: Unicode.Scalar) throws {
guard peek() == scalar else { throw Failure.invalidJSON }
index += 1
}
mutating func parseValue() throws -> Value {
skipWhitespace()
guard let scalar = peek() else { throw Failure.invalidJSON }
switch scalar {
case "{": return try parseObject()
case "[": return try parseArray()
case "\"": return .string(try parseString())
case "t":
try expectWord("true")
return .bool(true)
case "f":
try expectWord("false")
return .bool(false)
case "n":
try expectWord("null")
return .null
default:
return .integer(try parseNumber())
}
}
private mutating func expectWord(_ word: String) throws {
for scalar in word.unicodeScalars {
try expect(scalar)
}
}
private mutating func parseObject() throws -> Value {
try expect("{")
var members: [(key: String, value: Value)] = []
skipWhitespace()
if peek() == "}" {
index += 1
return .object(members)
}
while true {
skipWhitespace()
let key = try parseString()
skipWhitespace()
try expect(":")
let value = try parseValue()
// A repeated key takes the last occurrence, which is what Go's decoder and
// JSON.parse both do. Refusing here would make the phone stricter than the agent
// that recomputes the intent digest, and the two disagreeing about a body is the
// failure this whole file exists to prevent.
if let existing = members.firstIndex(where: { $0.key == key }) {
members[existing] = (key, value)
} else {
members.append((key, value))
}
skipWhitespace()
if peek() == "," {
index += 1
continue
}
try expect("}")
return .object(members)
}
}
private mutating func parseArray() throws -> Value {
try expect("[")
var items: [Value] = []
skipWhitespace()
if peek() == "]" {
index += 1
return .array(items)
}
while true {
items.append(try parseValue())
skipWhitespace()
if peek() == "," {
index += 1
continue
}
try expect("]")
return .array(items)
}
}
/// Parses a string, resolving escapes.
///
/// A `\uXXXX` escape in the surrogate range must be correctly paired. A lone surrogate is
/// an error here for the same reason it is in Go and Node: the three frameworks disagree
/// about what to do with one, so the only rule all three can share is to accept none.
private mutating func parseString() throws -> String {
try expect("\"")
var output = String.UnicodeScalarView()
while true {
guard let scalar = peek() else { throw Failure.invalidJSON }
index += 1
switch scalar {
case "\"":
return String(output)
case "\\":
output.append(try parseEscape())
default:
if scalar.value < 0x20 { throw Failure.invalidJSON }
output.append(scalar)
}
}
}
private mutating func parseEscape() throws -> Unicode.Scalar {
guard let scalar = peek() else { throw Failure.invalidJSON }
index += 1
switch scalar {
case "\"": return "\""
case "\\": return "\\"
case "/": return "/"
case "b": return Unicode.Scalar(0x08)
case "f": return Unicode.Scalar(0x0C)
case "n": return "\n"
case "r": return "\r"
case "t": return "\t"
case "u":
let high = try parseHex4()
if high >= 0xD800, high <= 0xDBFF {
guard peek() == "\\" else { throw Failure.loneSurrogate(high) }
index += 1
guard peek() == "u" else { throw Failure.loneSurrogate(high) }
index += 1
let low = try parseHex4()
guard low >= 0xDC00, low <= 0xDFFF else { throw Failure.loneSurrogate(high) }
let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
guard let scalar = Unicode.Scalar(combined) else { throw Failure.loneSurrogate(high) }
return scalar
}
guard high < 0xDC00 || high > 0xDFFF else { throw Failure.loneSurrogate(high) }
guard let scalar = Unicode.Scalar(high) else { throw Failure.loneSurrogate(high) }
return scalar
default:
throw Failure.invalidJSON
}
}
private mutating func parseHex4() throws -> UInt32 {
var value: UInt32 = 0
for _ in 0..<4 {
guard let scalar = peek(), let digit = hexDigit(scalar) else { throw Failure.invalidJSON }
index += 1
value = value << 4 | digit
}
return value
}
private func hexDigit(_ scalar: Unicode.Scalar) -> UInt32? {
switch scalar {
case "0"..."9": return scalar.value - 0x30
case "a"..."f": return scalar.value - 0x61 + 10
case "A"..."F": return scalar.value - 0x41 + 10
default: return nil
}
}
/// Parses an integer in the one spelling this protocol hashes.
///
/// The literal is kept as text rather than converted to a number, so what gets hashed is
/// what the phone sent. `1.0`, `1e3`, `+1`, `.5`, `01` and `-0` are all rejected: each of
/// them is the same number as some accepted spelling, and "which spelling did the phone
/// hash" is not a question this protocol leaves open.
private mutating func parseNumber() throws -> String {
var literal = ""
if peek() == "-" {
literal.append("-")
index += 1
}
guard let first = peek(), first >= "0", first <= "9" else {
throw Failure.invalidJSON
}
if first == "0" {
literal.append("0")
index += 1
if let next = peek(), next >= "0", next <= "9" {
throw Failure.nonCanonicalNumber(literal + String(next))
}
} else {
while let next = peek(), next >= "0", next <= "9" {
literal.append(Character(next))
index += 1
}
}
if let next = peek(), next == "." || next == "e" || next == "E" || next == "+" {
throw Failure.nonCanonicalNumber(literal + String(next))
}
if literal == "-0" { throw Failure.nonCanonicalNumber(literal) }
return literal
}
}
}
@@ -0,0 +1,146 @@
import CryptoKit
import Foundation
/// The phone's long-term relay identity: one Ed25519 signing key and one X25519 key-agreement key,
/// generated once and stored in the app sandbox.
///
/// The sandbox file rather than the keychain is a deliberate, recorded decision (design §6.4): the
/// established convention in this app is that node credentials live in a sandbox file, and the
/// Secure Enclave alternative conflicts with the TrollStore signing flow this build uses. The cost
/// is real and stated plainly: on a jailbroken device the file can be read, which yields the ability
/// to sign requests as this phone. It does not yield a node password, because the phone never holds
/// one in relay mode.
///
/// The device id is minted by the *node* at pairing time, not here: the node's config is the
/// authority for which device ids exist, and a phone that chose its own id could collide with an
/// existing pairing.
public struct DeviceIdentity: Sendable {
public let deviceID: String
public let signingKey: Curve25519.Signing.PrivateKey
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
public var signingPublicKey: Data { signingKey.publicKey.rawRepresentation }
public var agreementPublicKey: Data { agreementKey.publicKey.rawRepresentation }
public init(
deviceID: String,
signingKey: Curve25519.Signing.PrivateKey,
agreementKey: Curve25519.KeyAgreement.PrivateKey
) {
self.deviceID = deviceID
self.signingKey = signingKey
self.agreementKey = agreementKey
}
}
/// Generates and persists the device identity.
///
/// The file is written atomically with `completeFileProtection`, the same treatment the credential
/// store uses, so a locked device's backup does not carry the private key in the clear.
public struct FileDeviceIdentityStore: Sendable {
private struct FileModel: Codable {
var deviceID: String
var signingKey: Data
var agreementKey: Data
/// Bumped when the storage shape changes, so a future migration can tell old files apart
/// rather than guessing from missing fields.
var schemaVersion: Int
}
private let location: URL
private let lock = NSLock()
public init(location: URL) {
self.location = location
}
/// Loads the identity, creating one on first use.
///
/// The `deviceID` is a placeholder until the node assigns a real one at pairing: the phone needs
/// keys before it can pair, but it cannot know its id before the node decides. A pairing request
/// sends the public halves and receives the id, which is then stored here.
public func loadOrCreate() throws -> DeviceIdentity {
try lock.withLock {
if let model = read(), let identity = try? identity(from: model) {
return identity
}
let identity = DeviceIdentity(
deviceID: "",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
try write(identity)
return identity
}
}
/// Records the id the node assigned. The keys are unchanged: the node verified the public halves
/// it was given, and regenerating them would invalidate the pairing it just wrote.
public func assignDeviceID(_ deviceID: String) throws -> DeviceIdentity {
try lock.withLock {
let current = try loadOrCreateLocked()
let updated = DeviceIdentity(
deviceID: deviceID,
signingKey: current.signingKey,
agreementKey: current.agreementKey
)
try write(updated)
return updated
}
}
public func reset() throws {
try lock.withLock {
try? FileManager.default.removeItem(at: location)
}
}
// MARK: - Internals
/// `loadOrCreate` is not reentrant with the lock, so the locked body lives here.
private func loadOrCreateLocked() throws -> DeviceIdentity {
if let model = read(), let identity = try? identity(from: model) {
return identity
}
let identity = DeviceIdentity(
deviceID: "",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
try write(identity)
return identity
}
private func identity(from model: FileModel) throws -> DeviceIdentity {
DeviceIdentity(
deviceID: model.deviceID,
signingKey: try Curve25519.Signing.PrivateKey(rawRepresentation: model.signingKey),
agreementKey: try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: model.agreementKey)
)
}
private func read() -> FileModel? {
guard let data = try? Data(contentsOf: location) else { return nil }
return try? JSONDecoder().decode(FileModel.self, from: data)
}
private func write(_ identity: DeviceIdentity) throws {
let model = FileModel(
deviceID: identity.deviceID,
signingKey: identity.signingKey.rawRepresentation,
agreementKey: identity.agreementKey.rawRepresentation,
schemaVersion: 1
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
try FileManager.default.createDirectory(
at: location.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try encoder.encode(model).write(to: location, options: [.atomic, .completeFileProtection])
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: location.path
)
}
}
@@ -0,0 +1,279 @@
import Foundation
/// How a node is reached, as a value the list can badge.
public enum InstanceChannel: String, CaseIterable, Sendable {
case direct
case relay
public var label: String {
switch self {
case .direct: return "直通"
case .relay: return "中继"
}
}
}
public extension InstanceEndpoint {
var channel: InstanceChannel {
switch self {
case .direct: return .direct
case .relay: return .relay
}
}
}
/// How the app reaches a node.
///
/// This exists rather than an optional `relayNodeID` field on `Instance` because `origin` currently
/// carries three separate responsibilities — the request target, the duplicate-detection key, and
/// the cookie binding domain — and in relay mode all three have to be re-homed. An optional field
/// would leave two of them quietly pointing at an address that does not exist on a relay node.
public enum InstanceEndpoint: Equatable, Sendable {
/// The node is reachable directly on the local network. This is the original behaviour and its
/// code path is unchanged.
case direct(URL)
/// The node is reached through a relay, with the agent on the node holding the session against
/// the local SimAdmin. No node password is stored on the phone in this mode.
case relay(RelayEndpoint)
}
public struct RelayEndpoint: Equatable, Sendable {
/// The relay's base URL, https. The only outbound destination in relay mode.
public var relayBaseURL: URL
/// The node this endpoint reaches, as the relay and the agent both name it.
public var nodeID: String
/// `K_agent_sign` public half, Ed25519, 32 bytes. Every response is verified against it.
public var agentSigningKey: Data
/// `K_agent_kx` public half, X25519, 32 bytes. Requests are encrypted to it.
public var agentKxKey: Data
/// The bearer the relay expects from this phone. It authorises nothing on the node: the node's
/// authorisation comes from the signature the phone produces with its own private key.
///
/// A `String` rather than `Data`, which the design sketch drew: the relay compares the token as
/// an opaque bearer value, so what has to survive the round trip is the exact text the node
/// minted. Holding it as bytes invites a hex-encode on the way out, which sends a different
/// string than the one the agent registered and fails as "unknown device" with nothing to see.
public var deviceToken: String
/// The device id the node knows this phone by, and which the phone signs into every request.
public var deviceID: String
/// The node's display name at pairing time, used only when the user has not renamed it.
public var nodeName: String?
public init(
relayBaseURL: URL,
nodeID: String,
agentSigningKey: Data,
agentKxKey: Data,
deviceToken: String,
deviceID: String,
nodeName: String? = nil
) {
self.relayBaseURL = relayBaseURL
self.nodeID = nodeID
self.agentSigningKey = agentSigningKey
self.agentKxKey = agentKxKey
self.deviceToken = deviceToken
self.deviceID = deviceID
self.nodeName = nodeName
}
}
public extension InstanceEndpoint {
/// The direct origin, or nil in relay mode.
///
/// Callers that need "the address to dial directly" get nil rather than a substituted value, so
/// a relay instance cannot accidentally be handed to a code path that would dial a LAN address
/// that is not there.
var directOrigin: URL? {
if case .direct(let url) = self { return url }
return nil
}
var relay: RelayEndpoint? {
if case .relay(let endpoint) = self { return endpoint }
return nil
}
var isRelay: Bool { relay != nil }
/// The identity used for duplicate detection.
///
/// Direct endpoints keep using the normalised origin so an existing node list upgrades without
/// suddenly reporting duplicates; relay endpoints use the node id, because two phones paired to
/// the same node are two different instances and one phone paired to two nodes is not.
var dedupKey: String {
switch self {
case .direct(let url):
return url.absoluteString
case .relay(let endpoint):
return "relay:\(endpoint.nodeID)"
}
}
/// What the user sees as "where this node is".
var displayAddress: String {
switch self {
case .direct(let url):
return url.absoluteString
case .relay(let endpoint):
return "\(endpoint.relayBaseURL.host ?? "relay") · \(endpoint.nodeID)"
}
}
/// Whether the phone holds a node password for this instance.
///
/// The two modes mean different things and must not share a string: directly it means "this
/// phone has the node's password", through a relay it means "the node's agent has its own
/// credential". Showing the first wording in relay mode would tell the user their password is
/// on the phone when it deliberately is not.
var credentialDescription: String {
switch self {
case .direct:
return "已保存本机密码"
case .relay:
return "由节点 agent 保存凭据"
}
}
}
// MARK: - Codable
extension InstanceEndpoint: Codable {
private enum CodingKeys: String, CodingKey { case kind, url, relay }
private enum Kind: String, Codable { case direct, relay }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(Kind.self, forKey: .kind) {
case .direct:
self = .direct(try container.decode(URL.self, forKey: .url))
case .relay:
self = .relay(try container.decode(RelayEndpoint.self, forKey: .relay))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .direct(let url):
try container.encode(Kind.direct, forKey: .kind)
try container.encode(url, forKey: .url)
case .relay(let endpoint):
try container.encode(Kind.relay, forKey: .kind)
try container.encode(endpoint, forKey: .relay)
}
}
}
extension RelayEndpoint: Codable {
private enum CodingKeys: String, CodingKey {
case relayBaseURL, nodeID, agentSigningKey, agentKxKey, deviceToken, deviceID, nodeName
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
relayBaseURL = try container.decode(URL.self, forKey: .relayBaseURL)
nodeID = try container.decode(String.self, forKey: .nodeID)
agentSigningKey = try container.decode(Data.self, forKey: .agentSigningKey)
agentKxKey = try container.decode(Data.self, forKey: .agentKxKey)
deviceToken = try container.decode(String.self, forKey: .deviceToken)
deviceID = try container.decode(String.self, forKey: .deviceID)
nodeName = try container.decodeIfPresent(String.self, forKey: .nodeName)
guard agentSigningKey.count == RelayWire.keyBytes, agentKxKey.count == RelayWire.keyBytes else {
throw DecodingError.dataCorruptedError(
forKey: .agentSigningKey,
in: container,
debugDescription: "agent key material must be 32 raw bytes"
)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(relayBaseURL, forKey: .relayBaseURL)
try container.encode(nodeID, forKey: .nodeID)
try container.encode(agentSigningKey, forKey: .agentSigningKey)
try container.encode(agentKxKey, forKey: .agentKxKey)
try container.encode(deviceToken, forKey: .deviceToken)
try container.encode(deviceID, forKey: .deviceID)
try container.encodeIfPresent(nodeName, forKey: .nodeName)
}
}
// MARK: - Relay error vocabulary
/// Every code the relay can return, mirroring `relay/src/errors.js`.
///
/// The console's `ProblemDetails.code` is an unconstrained string in the OpenAPI document, so this
/// is a new vocabulary rather than a reuse of an existing one. It has to be enumerated in one place
/// in the first version, because three implementations writing their own strings is exactly how
/// cross-tier debugging stops working: the phone shows a code, the relay logs a code, and nobody can
/// match them.
public enum RelayErrorCode: String, CaseIterable, Sendable {
case unauthorized = "relay_unauthorized"
case nodeUnknown = "relay_node_unknown"
case agentOffline = "relay_agent_offline"
case mailboxFull = "relay_mailbox_full"
case agentTimeout = "relay_agent_timeout"
case badEnvelope = "relay_bad_envelope"
case payloadTooLarge = "relay_payload_too_large"
case rateLimited = "relay_rate_limited"
case internalError = "relay_internal"
case sessionExpired = "relay_session_expired"
case badChallenge = "relay_bad_challenge"
}
/// The agent's own classification, carried inside the ciphertext as `InnerResponse.outcome`.
///
/// Separate from `RelayErrorCode` because the two arrive through different channels and mean
/// different things: a relay code is "the relay could not deliver this", an agent outcome is "the
/// node received it and refused it". Collapsing them into one enum would lose the distinction the
/// user needs, which is whether retrying can help.
public enum AgentOutcome: String, CaseIterable, Sendable {
case ok
case badEnvelope = "bad_envelope"
case badSignature = "bad_signature"
case unknownDevice = "unknown_device"
case expired
case replay
case policyDenied = "policy_denied"
case confirmationMissing = "confirmation_missing"
case confirmationMismatch = "confirmation_mismatch"
case rateLimited = "rate_limited"
case payloadTooLarge = "payload_too_large"
case upstreamError = "upstream_error"
case nodeAuthFailed = "node_auth_failed"
}
/// Freshness of a snapshot, mirroring `FRESHNESS_STATUSES` in `packages/contracts/src/instances.ts`.
///
/// The four values are the existing vocabulary rather than new names. Without it a relay instance
/// cannot express that its "online" reading is forty seconds old, which in a two-hop architecture is
/// a substantive misstatement rather than a cosmetic one.
public enum SnapshotFreshness: String, CaseIterable, Sendable {
case fresh
case stale
case expired
case unknown
/// Thresholds, chosen to line up with what the UI already implies: a direct probe is fresh for
/// as long as a user would call the screen current, and anything past the relay's own request
/// deadline is expired rather than merely old.
public static func classify(age: TimeInterval?) -> SnapshotFreshness {
guard let age, age >= 0 else { return .unknown }
switch age {
case ..<15: return .fresh
case ..<90: return .stale
default: return .expired
}
}
public var label: String {
switch self {
case .fresh: return "刚刚"
case .stale: return "稍旧"
case .expired: return "已过期"
case .unknown: return "未知"
}
}
}
@@ -0,0 +1,125 @@
import CryptoKit
import Foundation
/// The phone's half of the pairing flow (design §7).
///
/// It works over the LAN, because at pairing time the phone is necessarily already inside the
/// network — that is the fact the whole flow leans on, and it is what lets the shared secret be the
/// node's own password instead of a QR code or a second credential to manage.
///
/// The node's agent serves this on a temporary management port that closes itself after one
/// successful pairing, so a phone that cannot reach the node has to be paired again from the node
/// rather than from anywhere else.
public struct RelayPairing: Sendable {
/// What the node returns on success.
public struct Result: Equatable, Sendable {
public let endpoint: RelayEndpoint
/// The device id the node assigned. It is the node's choice, not the phone's: the node's
/// config is the authority for which device ids exist.
public let deviceID: String
}
public enum Failure: Error, Equatable {
/// The shared secret did not match. Distinct from a transport failure because the remedy is
/// different: retype the password rather than check the network.
case rejected
/// The pairing window is closed — already used, or too many failed attempts.
case windowClosed
case unreachable
case malformedResponse
}
private let session: URLSession
private let port: Int
private let maximumBytes: Int
public init(session: URLSession = .shared, port: Int = 3001, maximumBytes: Int = 16 << 10) {
self.session = session
self.port = port
self.maximumBytes = maximumBytes
}
/// Pairs this phone with the node at `host`.
///
/// `host` is the node's LAN address as the user already typed it for direct mode, which is why
/// the port is a separate parameter: the pairing window is not on the SimAdmin port.
public func pair(
host: String,
password: String,
deviceName: String,
identity: DeviceIdentity
) async throws -> Result {
guard let url = URL(string: "http://\(host):\(port)/provision") else {
throw Failure.unreachable
}
// The device name is informational on the node's side, and the two public keys are the whole
// point: the node stores the signing key and returns its own, which becomes the phone's
// trust anchor for every later response.
let body: [String: String] = [
"password": password,
"appSignPub": identity.signingPublicKey.base64EncodedString(),
"appKxPub": identity.agreementPublicKey.base64EncodedString(),
"deviceName": deviceName,
]
var request = URLRequest(url: url, timeoutInterval: 15)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.setValue("application/json", forHTTPHeaderField: "accept")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw Failure.unreachable
}
guard let http = response as? HTTPURLResponse else { throw Failure.malformedResponse }
switch http.statusCode {
case 200:
break
case 401:
throw Failure.rejected
case 410:
throw Failure.windowClosed
default:
throw Failure.malformedResponse
}
guard data.count <= maximumBytes,
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let nodeID = root["nodeID"] as? String,
let appID = root["appID"] as? String,
let relayURLText = root["relayURL"] as? String,
let relayURL = URL(string: relayURLText),
relayURL.scheme == "https",
let signKey = Self.key(root["agentSignPub"] as? String),
let kxKey = Self.key(root["agentKxPub"] as? String),
let token = root["deviceToken"] as? String,
!token.isEmpty
else {
throw Failure.malformedResponse
}
return Result(
endpoint: RelayEndpoint(
relayBaseURL: relayURL,
nodeID: nodeID,
agentSigningKey: signKey,
agentKxKey: kxKey,
deviceToken: token,
deviceID: appID,
nodeName: deviceName
),
deviceID: appID
)
}
/// A key the node published must be exactly 32 bytes. A short one would be accepted by the
/// decoder and then fail as an unreadable response, which reads to the user as "the node is
/// broken" rather than "this pairing is wrong".
private static func key(_ text: String?) -> Data? {
guard let text, let data = Data(base64Encoded: text), data.count == RelayWire.keyBytes else {
return nil
}
return data
}
}
@@ -0,0 +1,366 @@
import CryptoKit
import Foundation
/// What the app needs in order to reach a node, in either mode.
///
/// The abstraction exists so services stop taking a `baseURL` and a `cookie`: in relay mode there is
/// no base URL to dial and no cookie, and a service that kept asking for one would be asking a
/// question with no answer.
///
/// `instanceID` is part of the signature even though the design sketch showed only the endpoint,
/// because the direct path scopes its cookie by `(instanceID, origin)` and losing that would let two
/// instances pointed at the same node share a session. It is the one piece of the sketch that could
/// not survive contact with the existing session store.
public protocol NodeTransport: Sendable {
func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse
}
/// Picks the transport for an endpoint.
///
/// Services depend on this rather than on either concrete transport, so adding a mode later is a
/// change in one place instead of in every feature that talks to a node.
public struct NodeTransportRouter: NodeTransport {
private let direct: any NodeTransport
private let relay: any NodeTransport
public init(direct: any NodeTransport, relay: any NodeTransport) {
self.direct = direct
self.relay = relay
}
public func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
switch endpoint {
case .direct:
return try await direct.send(route, instanceID: instanceID, endpoint: endpoint)
case .relay:
return try await relay.send(route, instanceID: instanceID, endpoint: endpoint)
}
}
}
/// The existing direct path, unchanged.
///
/// It is a thin adapter rather than a rewrite: the code path that reaches a node on the local network
/// today is the one users already depend on, and a refactor that changed its behaviour would be a
/// regression disguised as tidying.
public struct DirectTransport: NodeTransport {
private let client: any SimAdminClientProtocol
private let sessions: InstanceSessionStore
private let credentials: (any CredentialStore)?
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.client = client
self.sessions = sessions
self.credentials = credentials
}
public func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
guard let origin = endpoint.directOrigin else {
// Reaching here means a relay instance was handed to the direct path, which is a wiring
// bug rather than a network condition: fail loudly instead of dialing nothing.
throw UpstreamError.invalidRequest
}
var cookie = await sessions.cookie(for: instanceID, origin: origin)
do {
return try await client.send(route, baseURL: origin, cookie: cookie)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
// A node whose login endpoints are unreachable (an old build, or one with protection
// off) must not turn every read into a failed login attempt; the retry below is what
// makes the second call happen only when credentials actually exist.
guard let credentials, let password = try credentials.get(instanceID: instanceID),
!password.isEmpty
else {
throw UpstreamError.unauthorized
}
let service = InstanceSessionService(store: sessions, client: client)
let instance = Instance(
id: instanceID,
name: "",
endpoint: endpoint,
createdAt: Date(),
updatedAt: Date()
)
let result = try await service.login(instance, password: password)
guard result.authenticated else { throw UpstreamError.unauthorized }
cookie = await sessions.cookie(for: instanceID, origin: origin)
return try await client.send(route, baseURL: origin, cookie: cookie)
}
}
}
/// The relay path: build an envelope, sign it, encrypt it, post it, then verify and decrypt the
/// answer.
///
/// It never touches a cookie and never sees a node password. The node's agent holds that credential
/// and logs in to its own loopback SimAdmin, which is what makes "the phone has no node password"
/// true rather than merely stated.
public struct RelayTransport: NodeTransport {
/// Resolved per send rather than held, so a transport built before pairing still uses the keys
/// the node actually paired with. Holding a copy would mean a pairing done after the transport
/// was constructed signs with keys the node has never seen.
private let identity: @Sendable () throws -> DeviceIdentity
private let session: URLSession
private let maximumBytes: Int
private let now: @Sendable () -> Date
public init(
identity: @escaping @Sendable () throws -> DeviceIdentity,
session: URLSession = .shared,
maximumBytes: Int = 262_144,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.identity = identity
self.session = session
self.maximumBytes = maximumBytes
self.now = now
}
public init(
identity: DeviceIdentity,
session: URLSession = .shared,
maximumBytes: Int = 262_144,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.init(identity: { identity }, session: session, maximumBytes: maximumBytes, now: now)
}
public func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
guard let relay = endpoint.relay else { throw UpstreamError.invalidRequest }
guard relay.relayBaseURL.scheme == "https" else {
// Plain HTTP to a relay would make the endpoint-to-endpoint encryption decorative: a
// third party could substitute a different ciphertext, and nothing above would notice.
throw UpstreamError.invalidRequest
}
guard let requestBody = try? Self.encode(route: route) else {
throw UpstreamError.invalidRequest
}
_ = instanceID
guard let identity = try? identity() else {
throw UpstreamError.invalidRequest
}
// The ephemeral key pair is per request and its private half is kept only until the answer
// is decrypted. The response is encrypted to this key, so the two directions never share a
// secret and the phone's long-term X25519 key is not needed to read an answer.
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let requestID = UUID().uuidString.lowercased()
let timestamp = UInt64(now().timeIntervalSince1970)
let shared = try RelayWire.sharedSecret(
privateKey: ephemeral,
peerPublic: relay.agentKxKey
)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: relay.nodeID,
requestID: requestID,
response: false
)
let sealed = try RelayWire.seal(requestBody, key: key, iv: iv)
var envelope = RelayWire.Envelope(
node: relay.nodeID,
req: requestID,
app: relay.deviceID,
ts: timestamp,
ek: ephemeral.publicKey.rawRepresentation,
p: sealed
)
try RelayWire.signRequest(&envelope, privateKey: identity.signingKey)
let answer = try await post(envelope, relay: relay)
return try decrypt(answer, relay: relay, requestID: requestID, ephemeral: ephemeral)
}
// MARK: - Inner request
/// Builds the inner payload. The path is the route's own path, query string included, so the
/// agent never assembles one from parts and there is no traversal surface from the relay.
///
/// A dangerous write carries an intent computed here, from the exact bytes about to be sent. The
/// agent recomputes it and refuses a mismatch, so a relay cannot edit the recipient of an SMS in
/// flight and keep the confirmation valid. The intent is produced at the moment the user's
/// gesture is already behind us — this method is only called for a request the UI has decided to
/// make — which is what gives it any meaning at all.
static func encode(route: UpstreamRoute) throws -> Data {
let body = route.body
var inner = RelayWire.InnerRequest(m: route.method, p: route.path, body: body)
if route.requiresConfirmation {
inner.intent = try RelayWire.intentDigest(
method: route.method,
path: route.path,
body: body
)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return try encoder.encode(inner)
}
// MARK: - HTTP
private func post(_ envelope: RelayWire.Envelope, relay: RelayEndpoint) async throws -> RelayWire.Envelope {
guard let url = URL(string: relay.relayBaseURL.absoluteString + "/v1/request") else {
throw UpstreamError.invalidRequest
}
var request = URLRequest(url: url, timeoutInterval: 45)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.setValue("application/json", forHTTPHeaderField: "accept")
request.setValue("Bearer \(relay.deviceToken)", forHTTPHeaderField: "authorization")
request.httpBody = try JSONEncoder().encode(envelope)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch let error as URLError where error.code == .timedOut {
throw UpstreamError.relayTimeout
} catch {
throw UpstreamError.relayUnreachable
}
guard let http = response as? HTTPURLResponse, data.count <= maximumBytes else {
throw UpstreamError.invalidResponse
}
if http.statusCode != 200 {
throw Self.problem(from: data, status: http.statusCode)
}
guard let envelope = try? JSONDecoder().decode(RelayWire.Envelope.self, from: data) else {
throw UpstreamError.invalidResponse
}
return envelope
}
/// Maps a relay ProblemDetails document to a typed error.
///
/// The four classes the design calls for are kept apart on purpose: "the relay is unreachable",
/// "the agent is offline", "the envelope was refused" and "the node returned an error" lead to
/// four different user actions, and folding them into one "request failed" is what makes relay
/// problems unactionable.
static func problem(from data: Data, status: Int) -> UpstreamError {
let code = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])
.flatMap { $0?["code"] as? String }
.flatMap(RelayErrorCode.init(rawValue:))
switch code {
case .unauthorized:
// The relay rejected this phone's bearer. Nothing the node can do about it, and retrying
// with the same token will fail the same way.
return .relayRejectedDevice
case .nodeUnknown:
return .relayNodeUnknown
case .agentOffline:
return .agentOffline
case .mailboxFull:
return .nodeBusy
case .agentTimeout:
return .agentTimeout
case .badEnvelope:
return .relayBadEnvelope
case .payloadTooLarge:
return .payloadTooLarge
case .rateLimited:
return .relayRateLimited
case .sessionExpired, .badChallenge:
return .agentOffline
case .internalError, .none:
return status >= 500 ? .relayUnreachable : .invalidResponse
}
}
// MARK: - Response
private func decrypt(
_ envelope: RelayWire.Envelope,
relay: RelayEndpoint,
requestID: String,
ephemeral: Curve25519.KeyAgreement.PrivateKey
) throws -> UpstreamResponse {
guard envelope.req == requestID, envelope.node == relay.nodeID else {
// The relay answered a different question. Since the signature covers both fields, a
// mismatch here means the answer was not produced for this request at all.
throw UpstreamError.relayResponseMismatch
}
let signingKey: Curve25519.Signing.PublicKey
do {
signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: relay.agentSigningKey)
} catch {
throw UpstreamError.relayResponseUnverifiable
}
do {
try RelayWire.verifyResponse(envelope, publicKey: signingKey)
} catch {
// An answer the node did not sign is not an answer. This is the check that makes the
// relay's inability to forge responses a fact rather than a claim.
throw UpstreamError.relayResponseUnverifiable
}
let shared = try RelayWire.sharedSecret(privateKey: ephemeral, peerPublic: envelope.ek)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: relay.nodeID,
requestID: requestID,
response: true
)
let plaintext: Data
do {
plaintext = try RelayWire.open(envelope.p, key: key, iv: iv)
} catch {
throw UpstreamError.relayResponseUnverifiable
}
guard let inner = try? JSONDecoder().decode(RelayWire.InnerResponse.self, from: plaintext) else {
throw UpstreamError.invalidResponse
}
let outcome = inner.outcome.flatMap(AgentOutcome.init(rawValue:))
switch outcome {
case .ok, .none:
return UpstreamResponse(status: inner.status, data: inner.body ?? Data())
case .nodeAuthFailed:
// Nothing the phone can do: the node's agent is misconfigured. Distinguished from an
// upstream error because the remedy is on the node, not in the app.
throw UpstreamError.nodeAuthFailed
case .policyDenied, .unknownDevice:
throw UpstreamError.relayPolicyDenied
case .confirmationMissing, .confirmationMismatch:
throw UpstreamError.confirmationRejected
case .expired, .replay:
// The envelope was stale or already used. Both are clock or retry problems, and both
// resolve by re-issuing the request with a fresh id and timestamp.
throw UpstreamError.envelopeExpired
case .rateLimited:
throw UpstreamError.nodeRateLimited
case .payloadTooLarge:
throw UpstreamError.payloadTooLarge
case .badEnvelope, .badSignature:
throw UpstreamError.envelopeRejected
case .upstreamError:
// The node answered, but its local SimAdmin did not. The status is still useful: it is
// the only branch where a 4xx/5xx from the node reaches the caller.
return UpstreamResponse(status: inner.status, data: inner.body ?? Data())
}
}
}
extension Data {
/// Lowercase hex, which is the spelling the relay's bearer-token comparison expects.
var hexString: String { map { String(format: "%02x", $0) }.joined() }
}
@@ -0,0 +1,380 @@
import CryptoKit
import Foundation
/// The relay envelope: what is signed, what is encrypted, and in what order.
///
/// This is the third implementation of a format that Go and Node already implement, and the three
/// share no code on purpose. `contracts/test-vectors.json` is recomputed here by `RelayWireTests`,
/// so a byte-level disagreement shows up as a failing test rather than as a signature failure in
/// the field — where the only symptom is "the node refused the request" with no way to tell which
/// of the three encoders drifted.
///
/// The relay is not trusted, so signing and encryption are separate concerns: Ed25519 covers a
/// canonical byte encoding of the routing fields plus the ciphertext, which stops a compromised
/// relay from retargeting a request at another node or swapping a response between two in-flight
/// requests. AES-256-GCM then carries the payload under keys from an ephemeral X25519 exchange.
public enum RelayWire {
public static let version: UInt64 = 1
public static let suite = "X25519-ED25519-AES256GCM-HKDFSHA256"
static let domainRequest = "msa-relay-v1-req"
static let domainResponse = "msa-relay-v1-resp"
private static let label = "msa-relay-v1"
public static let keyBytes = 32
public static let ivBytes = 12
public static let tagBytes = 16
public enum Failure: Error, Equatable {
case unsupportedVersion(UInt64)
case unsupportedSuite(String)
case badKeyLength(field: String, got: Int)
case missingIdentityField
case signatureDoesNotVerify
case ciphertextFailedToAuthenticate
case peerKeyUnusable
case invalidResponseBody
}
// MARK: - Envelope
/// The outer, relay-visible structure. The relay routes on `node` and correlates on `req`, and
/// can read nothing else: `ek` is a public key, `p` is ciphertext, and `s` covers everything.
public struct Envelope: Codable, Equatable, Sendable {
public var v: UInt64
public var node: String
public var req: String
/// The paired device id. Present on requests only: it is signed, so the relay cannot
/// relabel one phone's traffic as another's, and the agent uses it to pick the key to
/// verify against.
public var app: String?
public var ts: UInt64
/// X25519 public key: the phone's per-request ephemeral on a request, the agent's
/// per-response ephemeral on a response.
public var ek: Data
public var alg: String
/// AES-256-GCM ciphertext with the 16-byte tag appended.
public var p: Data
public var s: Data?
public init(
v: UInt64 = RelayWire.version,
node: String,
req: String,
app: String? = nil,
ts: UInt64,
ek: Data,
alg: String = RelayWire.suite,
p: Data,
s: Data? = nil
) {
self.v = v
self.node = node
self.req = req
self.app = app
self.ts = ts
self.ek = ek
self.alg = alg
self.p = p
self.s = s
}
/// The wire form is base64 for the byte fields, and the key set is fixed: the relay parses
/// these same names, and the Go agent's struct tags are the authority for them.
private enum CodingKeys: String, CodingKey {
case v, node, req, app, ts, ek, alg, p, s
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
v = try container.decode(UInt64.self, forKey: .v)
node = try container.decode(String.self, forKey: .node)
req = try container.decode(String.self, forKey: .req)
app = try container.decodeIfPresent(String.self, forKey: .app)
ts = try container.decode(UInt64.self, forKey: .ts)
alg = try container.decode(String.self, forKey: .alg)
ek = try Self.decodeBase64(container.decode(String.self, forKey: .ek), field: "ek")
p = try Self.decodeBase64(container.decode(String.self, forKey: .p), field: "p")
let signature = try container.decodeIfPresent(String.self, forKey: .s) ?? ""
s = signature.isEmpty ? nil : try Self.decodeBase64(signature, field: "s")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(v, forKey: .v)
try container.encode(node, forKey: .node)
try container.encode(req, forKey: .req)
try container.encodeIfPresent(app, forKey: .app)
try container.encode(ts, forKey: .ts)
try container.encode(ek.base64EncodedString(), forKey: .ek)
try container.encode(alg, forKey: .alg)
try container.encode(p.base64EncodedString(), forKey: .p)
if let s {
try container.encode(s.base64EncodedString(), forKey: .s)
}
}
/// Strict base64: Go's decoder silently accepts some non-canonical forms, and a signature
/// must be computed over exactly the bytes the sender used, so a lenient decode here would
/// turn a malformed envelope into a valid-looking one.
private static func decodeBase64(_ value: String, field: String) throws -> Data {
guard let data = Data(base64Encoded: value, options: []) else {
throw Failure.invalidResponseBody
}
return data
}
}
/// The decrypted request payload.
public struct InnerRequest: Codable, Equatable, Sendable {
/// Upstream method. The agent matches it against its policy before dialing anything.
public var m: String
/// Upstream path, already carrying any query string. The agent forwards it verbatim and
/// never assembles one from parts, so there is no path-traversal surface from the relay.
public var p: String
/// sha256 over the method, path and canonical body, produced by an explicit user gesture.
/// Mandatory for every entry in the agent's `dangerous` list and absent for reads.
public var intent: Data?
/// Raw upstream body, forwarded unchanged. Kept as bytes so the agent never re-serialises
/// JSON and thereby never changes what the intent was computed over.
public var body: Data?
public init(m: String, p: String, intent: Data? = nil, body: Data? = nil) {
self.m = m
self.p = p
self.intent = intent
self.body = body
}
private enum CodingKeys: String, CodingKey { case m, p, intent, body }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
m = try container.decode(String.self, forKey: .m)
p = try container.decode(String.self, forKey: .p)
intent = try container.decodeIfPresent(Data.self, forKey: .intent)
body = try container.decodeIfPresent(Data.self, forKey: .body)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(m, forKey: .m)
try container.encode(p, forKey: .p)
if let intent { try container.encode(intent, forKey: .intent) }
if let body { try container.encode(body, forKey: .body) }
}
}
/// The decrypted response payload. Status lives inside the ciphertext so the relay cannot
/// rewrite a failure into a success.
public struct InnerResponse: Codable, Equatable, Sendable {
public var status: Int
/// The agent's own classification of what happened. Inside the ciphertext for the same
/// reason as `status`: the relay is not entitled to know why a command failed, and the
/// phone needs to tell the user what to do about it.
public var outcome: String?
public var body: Data?
public init(status: Int, outcome: String? = nil, body: Data? = nil) {
self.status = status
self.outcome = outcome
self.body = body
}
}
// MARK: - Canonical bytes
/// The signing input, which is not JSON.
///
/// Fixed field order, each field prefixed with its length as uint32be. The two integers carry
/// an explicit `00000008` length too — Go's `writeUint64` delegates to `writeBytes`, so a
/// version that emitted them bare would produce a byte string that looks identical in a hex
/// dump's payload region and still fails to verify.
///
/// `ek` and `p` contribute their raw decoded bytes, never their base64 text, so a relay cannot
/// re-encode them differently.
public static func canonical(_ envelope: Envelope, response: Bool) throws -> Data {
guard envelope.v == version else { throw Failure.unsupportedVersion(envelope.v) }
guard envelope.alg == suite else { throw Failure.unsupportedSuite(envelope.alg) }
guard !envelope.node.trimmed.isEmpty, !envelope.req.trimmed.isEmpty else {
throw Failure.missingIdentityField
}
guard envelope.ek.count == keyBytes else {
throw Failure.badKeyLength(field: "ek", got: envelope.ek.count)
}
if !response {
guard let app = envelope.app, !app.trimmed.isEmpty else { throw Failure.missingIdentityField }
}
var out = Data()
out.append(contentsOf: Array((response ? domainResponse : domainRequest).utf8))
out.append(0)
appendLengthPrefixed(&out, uint64be(envelope.v))
appendLengthPrefixed(&out, Data(envelope.node.utf8))
appendLengthPrefixed(&out, Data(envelope.req.utf8))
if !response, let app = envelope.app {
appendLengthPrefixed(&out, Data(app.utf8))
}
appendLengthPrefixed(&out, uint64be(envelope.ts))
appendLengthPrefixed(&out, envelope.ek)
appendLengthPrefixed(&out, envelope.p)
return out
}
private static func appendLengthPrefixed(_ out: inout Data, _ value: Data) {
var length = UInt32(value.count).bigEndian
withUnsafeBytes(of: &length) { out.append(contentsOf: $0) }
out.append(value)
}
private static func uint64be(_ value: UInt64) -> Data {
var big = value.bigEndian
return withUnsafeBytes(of: &big) { Data($0) }
}
// MARK: - Signing
public static func signRequest(_ envelope: inout Envelope, privateKey: Curve25519.Signing.PrivateKey) throws {
envelope.s = try privateKey.signature(for: canonical(envelope, response: false))
}
public static func verifyRequest(_ envelope: Envelope, publicKey: Curve25519.Signing.PublicKey) throws {
try verify(envelope, publicKey: publicKey, response: false)
}
public static func verifyResponse(_ envelope: Envelope, publicKey: Curve25519.Signing.PublicKey) throws {
try verify(envelope, publicKey: publicKey, response: true)
}
private static func verify(
_ envelope: Envelope,
publicKey: Curve25519.Signing.PublicKey,
response: Bool
) throws {
let canonicalBytes = try canonical(envelope, response: response)
guard let signature = envelope.s, signature.count == 64 else {
throw Failure.signatureDoesNotVerify
}
guard publicKey.isValidSignature(signature, for: canonicalBytes) else {
throw Failure.signatureDoesNotVerify
}
}
// MARK: - Key agreement and AEAD
/// HKDF-SHA256, extract then expand.
public static func hkdf(secret: Data, salt: Data, info: Data, length: Int) throws -> Data {
guard length > 0, length <= 255 * SHA256.byteCount else {
throw Failure.badKeyLength(field: "hkdf", got: length)
}
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: secret),
salt: salt,
info: info,
outputByteCount: length
)
return key.withUnsafeBytes { Data($0) }
}
/// X25519, rejecting the all-zero output that a peer key chosen to force a known shared secret
/// would produce.
public static func sharedSecret(
privateKey: Curve25519.KeyAgreement.PrivateKey,
peerPublic: Data
) throws -> Data {
guard peerPublic.count == keyBytes else {
throw Failure.badKeyLength(field: "peerPublic", got: peerPublic.count)
}
guard let publicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerPublic) else {
throw Failure.peerKeyUnusable
}
do {
let secret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
return secret.withUnsafeBytes { Data($0) }
} catch {
throw Failure.peerKeyUnusable
}
}
/// The AEAD key and IV for one direction of one request.
///
/// The salt binds the node id, so a shared secret that somehow repeats across nodes still
/// yields different keys. The IV is derived from the request id and the direction rather than
/// being a fixed zero block: a fixed IV is only safe while every message uses a fresh key, and
/// that invariant is exactly what a later "reuse the session key for a batch" optimisation
/// would quietly break.
public static func deriveKey(
shared: Data,
nodeID: String,
requestID: String,
response: Bool
) throws -> (key: SymmetricKey, iv: Data) {
guard shared.count == keyBytes else {
throw Failure.badKeyLength(field: "shared", got: shared.count)
}
let salt = Data("\(label):\(nodeID):\(requestID)".utf8)
let keyBytes = try hkdf(
secret: shared,
salt: salt,
info: Data((response ? "\(label)-resp" : "\(label)-req").utf8),
length: self.keyBytes
)
let iv = try hkdf(
secret: shared,
salt: Data(requestID.utf8),
info: Data((response ? "iv-resp" : "iv-req").utf8),
length: ivBytes
)
return (SymmetricKey(data: keyBytes), iv)
}
/// Encrypts with AES-256-GCM. The tag is appended to the ciphertext, matching Node's
/// `cipher.getAuthTag()` concatenation and Go's `gcm.Seal`.
public static func seal(_ plaintext: Data, key: SymmetricKey, iv: Data) throws -> Data {
guard iv.count == ivBytes else { throw Failure.badKeyLength(field: "iv", got: iv.count) }
let nonce = try AES.GCM.Nonce(data: iv)
let box = try AES.GCM.seal(plaintext, using: key, nonce: nonce)
return box.ciphertext + box.tag
}
/// Decrypts and authenticates. A failure is terminal for the request, not a retry: a wrong
/// key, a torn payload and a forged payload are indistinguishable here, and all three mean do
/// not act.
public static func open(_ ciphertext: Data, key: SymmetricKey, iv: Data) throws -> Data {
guard iv.count == ivBytes else { throw Failure.badKeyLength(field: "iv", got: iv.count) }
guard ciphertext.count >= tagBytes else { throw Failure.ciphertextFailedToAuthenticate }
let nonce = try AES.GCM.Nonce(data: iv)
let body = ciphertext.prefix(ciphertext.count - tagBytes)
let tag = ciphertext.suffix(tagBytes)
let box = try AES.GCM.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
do {
return try AES.GCM.open(box, using: key)
} catch {
throw Failure.ciphertextFailedToAuthenticate
}
}
// MARK: - Confirmation intent
/// sha256(method | path | canonical body): the value a phone sends to prove a human confirmed
/// this exact write.
///
/// The agent recomputes it from the request it is about to forward, so a relay cannot edit the
/// recipient of an SMS in flight and keep the confirmation valid. The body is canonicalised
/// the same way the web console does it, which is why `CanonicalJSON` exists as a shared,
/// test-vectored implementation rather than a call to `JSONSerialization`.
public static func intentDigest(method: String, path: String, body: Data?) throws -> Data {
let canonicalBody = try CanonicalJSON.canonicalize(body ?? Data())
var input = Data()
input.append(contentsOf: Array(method.trimmingCharacters(in: .whitespaces).uppercased().utf8))
input.append(UInt8(ascii: "|"))
input.append(contentsOf: Array(path.utf8))
input.append(UInt8(ascii: "|"))
input.append(canonicalBody)
return Data(SHA256.hash(data: input))
}
}
private extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>zh_CN</string>
<key>CFBundleDisplayName</key><string>MultiSimAdmin</string>
<key>CFBundleExecutable</key><string>MultiSimAdmin</string>
<key>CFBundleIdentifier</key><string>fun.chick.multi-simadmin</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.2</string>
<key>CFBundleVersion</key><string>10</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/>
</dict>
<key>NSPrincipalClass</key><string>UIApplication</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array><string>arm64</string></array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,138 @@
import Foundation
public protocol SimAdminClientProtocol: Sendable {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse
}
extension SimAdminClient: SimAdminClientProtocol {}
public enum ConnectionStatus: String, Equatable, Sendable {
case online
case offline
case authRequired
case degraded
case unknown
public var label: String {
switch self {
case .online: return "在线"
case .offline: return "离线"
case .authRequired: return "需登录"
case .degraded: return "降级"
case .unknown: return "未知"
}
}
}
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,
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 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.init(transport: DirectTransport(client: client, sessions: InstanceSessionStore()))
}
public func test(_ instance: Instance) async throws -> ConnectionResult {
let startedAt = DispatchTime.now()
do {
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(
status: .authRequired,
requestLatencyMilliseconds: latency,
checkedAt: Date()
)
}
return ConnectionResult(
status: .online,
requestLatencyMilliseconds: latency,
checkedAt: Date()
)
} catch {
let latency = Self.elapsedMilliseconds(since: startedAt)
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
}
}
private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? {
let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds
let milliseconds = Double(nanoseconds) / 1_000_000
return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil
}
}
@@ -0,0 +1,7 @@
import Foundation
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}
@@ -0,0 +1,109 @@
import Foundation
enum DefaultContainer {
private static let credentials: any CredentialStore = FileCredentialStore(
location: documentFileURL("credentials.json")
)
private static let logStore: any LogStore = FileLogStore(
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: makeInstanceRepository(),
probe: ConnectionProbe(transport: makeTransport(client: SimAdminClient(), sessions: sessions)),
credentials: credentials,
deviceService: DeviceService(
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
)
)
}
/// 0.2.2 起分组被标签取代,遗留的分组文件与偏好键一次性清掉,避免 Documents 里留死数据。
private static func purgeLegacyGrouping() {
try? FileManager.default.removeItem(at: documentFileURL("instance-groups.json"))
UserDefaults.standard.removeObject(forKey: "fleet.groupMode")
}
@MainActor
static func makeMessagesModel() -> MessagesViewModel {
let sessions = InstanceSessionStore()
return MessagesViewModel(
repository: makeInstanceRepository(),
service: MessageService(
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
)
)
}
@MainActor
static func makeLogsModel(autoRefreshOnAppear: Bool = true) -> LogsViewModel {
let store = logStore
let sessions = InstanceSessionStore()
return LogsViewModel(
repository: makeInstanceRepository(),
service: LogCenterService(
transport: makeTransport(client: SimAdminClient(), sessions: sessions),
store: store
),
store: store,
autoRefreshOnAppear: autoRefreshOnAppear
)
}
@MainActor
static func makeSettingsModel() -> SettingsViewModel {
SettingsViewModel(store: logStore)
}
@MainActor
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
let sessions = InstanceSessionStore()
return DeviceViewModel(
instance: instance,
service: DeviceService(
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
),
probe: ConnectionProbe(
transport: makeTransport(client: SimAdminClient(), sessions: sessions)
)
)
}
}
@@ -0,0 +1,52 @@
import Foundation
public final class FileCredentialStore: @unchecked Sendable, CredentialStore {
private struct FileModel: Codable {
var credentials: [String: String] = [:]
}
private let location: URL
private let lock = NSLock()
private var fileModel = FileModel()
public init(location: URL) {
self.location = location
lock.withLock {
if let data = try? Data(contentsOf: location),
let decoded = try? JSONDecoder().decode(FileModel.self, from: data) {
fileModel = decoded
}
}
}
public func get(instanceID: UUID) throws -> String? {
lock.withLock {
fileModel.credentials[instanceID.uuidString]
}
}
public func set(instanceID: UUID, password: String) throws {
try lock.withLock {
fileModel.credentials[instanceID.uuidString] = password
try write()
}
}
public func remove(instanceID: UUID) throws {
try lock.withLock {
fileModel.credentials.removeValue(forKey: instanceID.uuidString)
try write()
}
}
private func write() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(fileModel)
try data.write(to: location, options: [.atomic, .completeFileProtection])
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: location.path
)
}
}
@@ -0,0 +1,74 @@
import Foundation
public struct SessionMetadata: Equatable, Sendable {
public let instanceID: UUID
public let authenticated: Bool
public let checkedAt: Date
public init(instanceID: UUID, authenticated: Bool, checkedAt: Date) {
self.instanceID = instanceID
self.authenticated = authenticated
self.checkedAt = checkedAt
}
}
public struct InstanceSessionService: Sendable {
private let store: InstanceSessionStore
private let client: any SimAdminClientProtocol
public init(store: InstanceSessionStore, client: any SimAdminClientProtocol) {
self.store = store
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: origin,
cookie: nil
)
if let cookie = Self.sessionCookie(response.setCookie) {
await store.set(instance.id, origin: origin, cookie: cookie)
return SessionMetadata(instanceID: instance.id, authenticated: true, checkedAt: Date())
}
} catch {
// Rejected credentials fall through to the same explicit unauthenticated state.
}
await store.clear(instance.id)
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
}
public func logout(_ instance: Instance) async throws -> SessionMetadata {
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? {
guard let origin = instance.endpoint.directOrigin else { return nil }
return await store.cookie(for: instance.id, origin: origin)
}
static func sessionCookie(_ header: String?) -> String? {
guard let header,
let range = header.range(of: "simadmin_session=([^;\\s,]+)", options: .regularExpression)
else {
return nil
}
return String(header[range])
}
}
@@ -0,0 +1,10 @@
import Foundation
/// 仅 DEBUG 构建可用的启动开关,用于在模拟器里直接打开某个界面做视觉回归。
#if DEBUG
enum DebugLaunch {
private static let args = ProcessInfo.processInfo.arguments
static func has(_ flag: String) -> Bool { args.contains(flag) }
}
#endif
@@ -0,0 +1,63 @@
import Foundation
/// User-facing text for every failure the app can produce.
///
/// The relay work is what forced this out of `ConnectionProbe`: with one hop there was one thing
/// that could go wrong, and "请求失败" was an acceptable summary. With two hops there are four
/// distinct situations that call for four different actions, and a message that cannot tell them
/// apart is the difference between a five-second fix and a support thread.
public enum ErrorMessage {
public static let network = "暂时无法连接实例,请稍后重试。"
public static let unauthorized = "实例登录状态已失效,请重新登录。"
// MARK: - Relay mode
/// Describes a relay-mode failure in terms of what the user should do next.
public static func describe(_ error: UpstreamError, nodeName: String) -> String {
switch error {
case .relayUnreachable:
return "无法连接中继服务器。请检查网络,或确认中继是否在运行。"
case .relayTimeout:
return "中继响应超时。稍后重试;如果反复出现,请检查中继负载。"
case .relayRejectedDevice:
return "中继已不再接受这台设备的凭据,需要在节点上重新配对。"
case .relayNodeUnknown:
return "中继不认识节点 \(nodeName)。可能节点已离线并被移出,或这台手机未与该节点配对。"
case .agentOffline:
return "节点 \(nodeName) 的 agent 当前未连接到中继。等它上线后重试。"
case .nodeBusy:
return "节点 \(nodeName) 正在处理其它请求,稍后重试。"
case .agentTimeout:
// The one message that must not invite a blind retry: the node may have executed it.
return "节点 \(nodeName) 未在时限内应答。该操作可能已经执行,请先确认结果再决定是否重试。"
case .relayBadEnvelope:
return "中继拒绝了发往节点 \(nodeName) 的请求信封。这通常意味着两端版本不一致。"
case .relayRateLimited:
return "对节点 \(nodeName) 的请求过于频繁,已被中继限流。稍后再试。"
case .relayPolicyDenied:
return "节点 \(nodeName) 的 agent 不允许该操作。该路径不在它的授权范围内。"
case .confirmationRejected:
return "节点 \(nodeName) 拒绝了这次确认:确认信息与请求不匹配,操作未执行。请重新发起。"
case .envelopeExpired:
return "对节点 \(nodeName) 的请求已过期或重复。请重新发起;如果反复出现,请检查手机与节点的时钟。"
case .nodeRateLimited:
return "节点 \(nodeName) 限制了危险写操作频率,稍后再试。"
case .nodeAuthFailed:
return "节点 \(nodeName) 的 agent 无法登录本机 SimAdmin。需要在节点上修正 agent 的密码配置。"
case .payloadTooLarge:
return "请求内容超过节点 \(nodeName) 允许的大小。"
case .relayResponseUnverifiable:
return "节点 \(nodeName) 的应答未通过签名校验,已丢弃。这可能是中继被篡改或密钥不匹配。"
case .relayResponseMismatch:
return "中继返回了与请求不匹配的应答,已丢弃。"
case .envelopeRejected:
return "节点 \(nodeName) 拒绝了该请求的签名或格式。可能需要在节点上重新配对。"
case .unauthorized:
return unauthorized
case .timeout, .transportFailed:
return network
default:
return "请求失败:\(error)"
}
}
}
@@ -0,0 +1,84 @@
import Foundation
/// 上游返回的时间戳格式并不统一(`yyyy-MM-dd HH:mm:ss`、ISO8601、秒/毫秒时间戳都出现过),
/// 列表里需要一个稳定的短读法,解析不出来时原样回退,避免出现"--"这种假结论。
public enum TimestampText {
private static let parsers: [DateFormatter] = {
let patterns = [
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"yyyy-MM-dd",
]
return patterns.map { pattern in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = .current
formatter.dateFormat = pattern
return formatter
}
}()
private static let utcParser: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return formatter
}()
private static let timeFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter
}()
private static let monthDayFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "MM-dd HH:mm"
return formatter
}()
private static let fullFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
public static func date(from raw: String) -> Date? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.isEmpty == false else { return nil }
if let number = Double(text), text.allSatisfy({ $0.isNumber }) {
// 13 位是毫秒,10 位是秒。
return Date(timeIntervalSince1970: text.count > 11 ? number / 1000 : number)
}
let head = text.hasSuffix("Z") ? String(text.dropLast()) : text
for parser in parsers {
if let parsed = parser.date(from: head) {
return text.hasSuffix("Z") ? utcParser.date(from: head) ?? parsed : parsed
}
}
return nil
}
/// 今天只看时分,今年看月日,跨年才写全年份。
public static func short(_ raw: String, now: Date = Date()) -> String {
guard let parsed = date(from: raw) else {
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
}
let calendar = Calendar.current
if calendar.isDate(parsed, inSameDayAs: now) {
return timeFormat.string(from: parsed)
}
if calendar.isDate(parsed, equalTo: now, toGranularity: .year) {
return monthDayFormat.string(from: parsed)
}
return fullFormat.string(from: parsed)
}
}
@@ -0,0 +1,157 @@
import SwiftUI
public struct AIButtonStyle: ButtonStyle {
public static let pressedScale: CGFloat = 0.97
public enum Kind: Hashable {
case primary
case secondary
case danger
var foregroundColor: Color { foregroundColor(isEnabled: true) }
var backgroundColor: Color { backgroundColor(isEnabled: true) }
func foregroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary: return isEnabled ? AITheme.onPrimary : AITheme.muted
case .danger: return isEnabled ? AITheme.onDanger : AITheme.muted
case .secondary: return isEnabled ? AITheme.ink : AITheme.muted
}
}
func backgroundColor(isEnabled: Bool) -> Color {
if isEnabled { return kindBackground }
// 禁用态:实心主色按钮保留主色淡版,其余退回中性灰。
return self == .primary
? AITheme.primary.opacity(AITheme.disabledFillOpacity)
: AITheme.control
}
private var kindBackground: Color {
switch self {
case .primary: return AITheme.primary
case .secondary: return AITheme.control
case .danger: return AITheme.danger
}
}
/// 禁用态下主按钮必须还能看出"这是主行动"。
/// 之前三种 kind 禁用后都塌成同一块 control 灰,表单里"取消"和"保存"长得一模一样,
/// 用户只能靠文字猜哪个是提交。这里让实心主色按钮改用同色相淡填充 + 描边,灰底次按钮不套。
var usesTintedDisabledTreatment: Bool {
self == .primary
}
}
public enum Size: Hashable {
case compact
case regular
case large
case icon
case toolbar
case toolbarInline
}
private let kind: Kind
private let size: Size
private let fixedWidthRequest: CGFloat?
let fillsWidth: Bool
@Environment(\.isEnabled) private var isEnabled
public init(
kind: Kind = .secondary,
size: Size = .regular,
fixedWidth: CGFloat? = nil,
fillsWidth: Bool = false
) {
self.kind = kind
self.size = size
self.fixedWidthRequest = fixedWidth
self.fillsWidth = fillsWidth
}
public func makeBody(configuration: Configuration) -> some View {
let height: CGFloat =
switch size {
case .compact: AITheme.compactButtonHeight
case .large: AITheme.largeButtonHeight
case .icon: AITheme.iconButtonSize
case .regular: AITheme.buttonHeight
case .toolbar: AITheme.toolbarButtonHeight
case .toolbarInline: AITheme.toolbarButtonHeight
}
let isTintedDisabled = kind.usesTintedDisabledTreatment && !isEnabled
configuration.label
.labelStyle(.titleAndIcon)
.font(
.system(size: size == .compact || size == .toolbarInline ? 15 : 16, weight: .semibold)
)
.lineLimit(1)
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
.frame(
minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0,
minHeight: height
)
.frame(maxWidth: fillsWidth ? .infinity : nil)
.frame(width: fixedWidth)
.background(
// 主色面用纯色。之前的 primaryTop/primaryBottom 竖向渐变在系统里会被合成成灰紫,
// 实测 #3B82F6 掉到 #6B81CA,整片按钮发脏。
AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled))
)
.shadow(
color: (kind == .secondary || isTintedDisabled) ? .clear : .black.opacity(0.10),
radius: 12,
x: 0,
y: 5
)
.clipShape(
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
)
.overlay {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.strokeBorder(
isTintedDisabled
? AITheme.primary.opacity(AITheme.disabledStrokeOpacity)
: size == .toolbarInline && kind == .primary
? AITheme.primary.opacity(0.28)
: (kind == .secondary || !isEnabled ? AITheme.line : .clear)
)
}
.scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
.opacity(configuration.isPressed ? 0.82 : 1)
}
/// 工具栏里的图标按钮需要更小的圆角,否则 34pt 方块看起来像被切过。
private var cornerRadius: CGFloat {
switch size {
case .toolbarInline: return AITheme.toolbarButtonHeight / 2
case .icon: return AITheme.iconButtonRadius
default: return AITheme.buttonRadius
}
}
private var minimumWidth: CGFloat {
switch size {
case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth
case .large: return 92
case .icon: return AITheme.iconButtonSize
case .toolbar, .toolbarInline: return AITheme.toolbarButtonMinWidth
}
}
private var fixedWidth: CGFloat? {
fixedWidthRequest ?? (size == .icon ? AITheme.iconButtonSize : (size == .toolbar ? AITheme.toolbarButtonWidth : nil))
}
}
@@ -0,0 +1,924 @@
import SwiftUI
public struct AICard<Content: View>: View {
private let content: Content
private let padding: CGFloat
private let emphasis: Bool
public init(
padding: CGFloat = 16,
emphasis: Bool = false,
@ViewBuilder content: () -> Content
) {
self.padding = padding
self.emphasis = emphasis
self.content = content()
}
public var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(padding)
.background(
AICard.backgroundGradient(
raisedTop: emphasis
)
)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
.shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6)
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
.strokeBorder(AITheme.line)
}
}
}
extension AICard {
fileprivate static func backgroundGradient(raisedTop: Bool) -> LinearGradient {
LinearGradient(
colors: raisedTop
? [AITheme.surfaceRaised, AITheme.surfaceRaised.opacity(0.94)]
: [AITheme.surfaceRaised, AITheme.surface],
startPoint: .top,
endPoint: .bottom
)
}
}
public struct AITag: View {
private let text: String
private let tint: Color?
private let systemImage: String?
/// 不传 `tint` 时是中性归类标签(标签、方向这类不带健康含义的信息),
/// 传了 `tint` 才是状态标签,用同色色雾与描边。颜色只留给后者。
public init(_ text: String, tint: Color? = nil, systemImage: String? = nil) {
self.text = text
self.tint = tint
self.systemImage = systemImage
}
public var body: some View {
let tint = self.tint
HStack(spacing: 4) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
}
Text(text)
.font(.caption2.weight(.semibold))
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 10)
// 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见,
// 所以描边权重必须够,不能让标签在暗色主题里消失。
.frame(height: 28)
.foregroundStyle(tint == nil ? AITheme.ink : tint!)
.background(tint.map { $0.opacity(0.14) } ?? AITheme.tile)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.map { $0.opacity(0.30) } ?? AITheme.tileLine)
}
.accessibilityLabel(text)
}
}
public struct AISectionTitle: View {
private let title: String
private let systemImage: String
private let detail: String?
private let footer: Footer
public init(
_ title: String,
systemImage: String,
detail: String? = nil,
actionTitle: String? = nil,
actionSystemImage: String? = nil,
actionKind: AIButtonStyle.Kind = .secondary,
action: (() -> Void)? = nil
) {
self.title = title
self.systemImage = systemImage
self.detail = detail
self.footer = Footer(
title: actionTitle,
systemImage: actionSystemImage,
kind: actionKind,
action: action
)
}
private struct Footer: View {
let title: String?
let systemImage: String?
let kind: AIButtonStyle.Kind
let action: (() -> Void)?
var body: some View {
if let title, let action {
Button {
action()
} label: {
if let systemImage {
Label(title, systemImage: systemImage)
} else {
Text(title)
}
}
.buttonStyle(AIButtonStyle(kind: kind, size: .compact))
}
}
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.primary)
.frame(width: 22, height: 22)
.accessibilityHidden(true)
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
if let detail {
Text(detail)
.font(.caption.weight(.medium))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
}
if Footer.self != EmptyView.self {
footer
}
}
}
}
public struct AIFieldLabel: View {
private let title: String
public init(_ title: String) {
self.title = title
}
public var body: some View {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
}
}
extension View {
/// 卡片内的二级面板:极浅底色 + 更浅的描边,只做"分组",不抢主信息的对比度。
func aiInsetSurface(cornerRadius: CGFloat = 12) -> some View {
self
.background(AITheme.tile)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(AITheme.tileLine)
}
}
}
/// 带语义色的内嵌指标块:底色/描边都由 tint 决定,浅色下是一层很淡的色洗。
struct AITintTile<Content: View>: View {
private let tint: Color
private let content: Content
init(tint: Color, @ViewBuilder content: () -> Content) {
self.tint = tint
self.content = content()
}
var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.09))
.clipShape(RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous)
.strokeBorder(tint.opacity(0.20))
}
}
}
public struct AIStatCard: View {
private let title: String
private let value: String
private let detail: String?
private let tint: Color
private let systemImage: String?
public init(
title: String,
value: String,
detail: String? = nil,
systemImage: String? = nil,
tint: Color
) {
self.title = title
self.value = value
self.detail = detail
self.systemImage = systemImage
self.tint = tint
}
public var body: some View {
// 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。
HStack(alignment: .center, spacing: 10) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 30, height: 30)
.background(tint.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption2.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.85)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.headline.weight(.semibold))
.monospacedDigit()
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface(cornerRadius: AITheme.tileRadius)
.accessibilityElement(children: .combine)
}
}
public struct AIStatusBanner: View {
private let message: String
private let tint: Color
private let systemImage: String
public init(_ message: String, tint: Color, systemImage: String) {
self.message = message
self.tint = tint
self.systemImage = systemImage
}
public var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(message)
.font(.subheadline.weight(.medium))
.foregroundStyle(tint)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(15)
.background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityElement(children: .combine)
}
}
public struct AIEmptyState: View {
private let systemImage: String
private let title: String
private let message: String
private let actionTitle: String?
private let actionSystemImage: String
private let action: (() -> Void)?
public init(systemImage: String, title: String, message: String) {
self.systemImage = systemImage
self.title = title
self.message = message
self.actionTitle = nil
self.actionSystemImage = "arrow.clockwise"
self.action = nil
}
/// 失败/空态带一个就地重试入口,避免"红色横幅 + 暂无数据"互相矛盾。
public init(
systemImage: String,
title: String,
message: String,
actionTitle: String,
actionSystemImage: String = "arrow.clockwise",
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.message = message
self.actionTitle = actionTitle
self.actionSystemImage = actionSystemImage
self.action = action
}
public var body: some View {
VStack(spacing: 10) {
Image(systemName: systemImage)
.font(.system(size: 27, weight: .medium))
.foregroundStyle(AITheme.primary)
.frame(width: 54, height: 54)
.background(AITheme.primary.opacity(0.10))
.clipShape(Circle())
.accessibilityHidden(true)
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Text(message)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if let actionTitle, let action {
Button(action: action) {
Label(actionTitle, systemImage: actionSystemImage)
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
.padding(.top, 4)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.accessibilityElement(children: .combine)
}
}
public struct AIListCard<Footer: View>: View {
private let title: String
private let subtitle: String?
private let status: String?
private let statusTint: Color?
private let details: [String]
private let footer: Footer
public init(
title: String,
subtitle: String? = nil,
status: String? = nil,
statusTint: Color? = nil,
details: [String] = [],
@ViewBuilder footer: () -> Footer = { EmptyView() }
) {
self.title = title
self.subtitle = subtitle
self.status = status
self.statusTint = statusTint
self.details = details
self.footer = footer()
}
public var body: some View {
AICard {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(2)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
if let status {
AITag(status, tint: statusTint ?? AITheme.muted)
}
}
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !details.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(details, id: \.self) { detail in
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if Footer.self != EmptyView.self {
footer
}
}
}
}
}
public struct AILoadingCard: View {
private let message: String
public init(_ message: String) {
self.message = message
}
public var body: some View {
VStack(spacing: 10) {
ProgressView()
.controlSize(.regular)
.frame(width: 44, height: 44)
.background(AITheme.control)
.clipShape(Circle())
Text(message)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(16)
.background(
LinearGradient(
colors: [AITheme.surfaceRaised, AITheme.surface],
startPoint: .top,
endPoint: .bottom
)
)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
.shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10)
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
.strokeBorder(AITheme.line)
}
.accessibilityElement(children: .combine)
}
}
public struct AIActionRow<Content: View>: View {
private let spacing: CGFloat
private let content: Content
public init(
spacing: CGFloat = 10,
@ViewBuilder content: () -> Content
) {
self.spacing = spacing
self.content = content()
}
public var body: some View {
AIEqualWidthLayout(spacing: spacing) {
content
}
}
}
private struct AIEqualWidthLayout: Layout {
let spacing: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let count = subviews.count
let width: CGFloat
if let proposedWidth = proposal.width {
width = proposedWidth
} else {
let idealWidths = subviews.map { $0.sizeThatFits(.unspecified).width }
width = idealWidths.reduce(0, +) + spacing * CGFloat(count - 1)
}
let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count))
let height =
subviews.map {
$0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height
}.max() ?? 0
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard !subviews.isEmpty else { return }
let equalWidth = max(
0,
(bounds.width - spacing * CGFloat(subviews.count - 1)) / CGFloat(subviews.count)
)
var x = bounds.minX
for subview in subviews {
let size = subview.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil))
subview.place(
at: CGPoint(x: x, y: bounds.midY - size.height / 2),
proposal: ProposedViewSize(width: equalWidth, height: size.height)
)
x += equalWidth + spacing
}
}
}
public struct AIKeyValueRow: View {
private let title: String
private let value: String
private let valueTint: Color
private let monospaced: Bool
public init(_ title: String, value: String, tint: Color = AITheme.ink, monospaced: Bool = false) {
self.title = title
self.value = value
self.valueTint = tint
self.monospaced = monospaced
}
public var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(title)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.layoutPriority(1)
Spacer(minLength: 12)
Text(value)
.font(
monospaced
? Font.system(.subheadline, design: .monospaced, weight: .medium)
: Font.subheadline.weight(.medium)
)
.monospacedDigit()
.foregroundStyle(valueTint)
.multilineTextAlignment(.trailing)
.lineLimit(2)
.truncationMode(.middle)
.minimumScaleFactor(0.85)
.textSelection(.enabled)
.layoutPriority(2)
}
.accessibilityElement(children: .combine)
}
}
public struct AISegmentedControl<Value: Hashable>: View {
private let title: String
private let options: [(value: Value, title: String)]
@Binding private var selection: Value
public init(
_ title: String,
selection: Binding<Value>,
options: [(value: Value, title: String)]
) {
self.title = title
self._selection = selection
self.options = options
}
public var body: some View {
HStack(spacing: 3) {
ForEach(options, id: \.value) { option in
let isSelected = selection == option.value
Button {
selection = option.value
} label: {
Text(option.title)
.font(.subheadline.weight(isSelected ? .semibold : .medium))
.lineLimit(1)
.minimumScaleFactor(0.75)
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
// 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。
.background {
RoundedRectangle(cornerRadius: 11, style: .continuous)
.fill(isSelected ? AITheme.segmentSelected : Color.clear)
.shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 3, x: 0, y: 1)
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityLabel(option.title)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
.padding(3)
.background(AITheme.segmentTrack)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(AITheme.tileLine)
}
.animation(.easeOut(duration: 0.16), value: selection)
.accessibilityElement(children: .contain)
.accessibilityLabel(title)
}
}
public struct AIFieldStyle: TextFieldStyle {
@FocusState private var isFocused: Bool
public init() {}
public func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.textFieldStyle(.plain)
.focused($isFocused)
.aiInputSurface()
}
}
public struct AISearchField: View {
private let placeholder: String
@Binding private var text: String
private var onSubmit: (() -> Void)?
private let minHeight: CGFloat
@FocusState private var isFocused: Bool
public init(
_ placeholder: String,
text: Binding<String>,
minHeight: CGFloat = AITheme.buttonHeight,
onSubmit: (() -> Void)? = nil
) {
self.placeholder = placeholder
self._text = text
self.minHeight = minHeight
self.onSubmit = onSubmit
}
public var body: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.focused($isFocused)
.submitLabel(.search)
.onSubmit {
onSubmit?()
}
if !text.isEmpty {
Button {
text = ""
onSubmit?()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AITheme.muted)
.font(.system(size: 16, weight: .medium))
}
.buttonStyle(.plain)
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
.contentShape(Rectangle())
.accessibilityLabel("清除搜索")
}
}
.aiInputSurface(minHeight: minHeight, isFocused: isFocused)
}
}
extension View {
func aiInputSurface(
minHeight: CGFloat = AITheme.inputHeight,
isFocused: Bool = false
) -> some View {
self
.padding(.horizontal, 12)
.frame(minHeight: minHeight, alignment: .leading)
.background(isFocused ? AITheme.surfaceRaised : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(isFocused ? AITheme.primary.opacity(0.55) : AITheme.line)
}
.animation(.easeOut(duration: 0.15), value: isFocused)
}
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
safeAreaInset(edge: .bottom, spacing: 0) {
content()
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 10)
.background {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
Rectangle()
.fill(AITheme.surface.opacity(0.98))
}
.ignoresSafeArea(edges: .bottom)
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
func aiSelectSurface(compact: Bool = false) -> some View {
self
.tint(AITheme.ink)
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
.aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight)
}
}
public struct AIToolbarButton: View {
private let title: String
private let systemImage: String?
private let kind: AIButtonStyle.Kind
private let action: () -> Void
public init(
_ title: String,
systemImage: String? = nil,
kind: AIButtonStyle.Kind = .secondary,
action: @escaping () -> Void
) {
self.title = title
self.systemImage = systemImage
self.kind = kind
self.action = action
}
public var body: some View {
Button {
action()
} label: {
if let systemImage {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.font(.system(size: 15, weight: .semibold))
} else {
Text(title)
.font(.system(size: 15, weight: .semibold))
}
}
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbarInline))
.accessibilityLabel(title)
}
}
public struct AIIconButton: View {
private let title: String
private let systemImage: String
private let kind: AIButtonStyle.Kind
private let action: () -> Void
public init(
_ title: String,
systemImage: String,
kind: AIButtonStyle.Kind = .secondary,
action: @escaping () -> Void
) {
self.title = title
self.systemImage = systemImage
self.kind = kind
self.action = action
}
public var body: some View {
Button {
action()
} label: {
Image(systemName: systemImage)
.font(.system(size: 16, weight: .semibold))
}
.buttonStyle(AIButtonStyle(kind: kind, size: .icon))
.accessibilityLabel(title)
}
}
/// 列表行内的轻量图标动作:同色淡底小方块,触区仍补足 44pt。
/// 行内操作用实心药丸会把一行读成"按钮墙",这里让颜色只表达危险程度。
public struct AISymbolAction: View {
private let title: String
private let systemImage: String
private let tint: Color
/// 安静样式:只留着色图标与点击区,不再铺色块。
/// 卡片列表里每行都放一块彩色方块会把整页染成"警告",删除这类低频动作更适合安静样式。
private let isQuiet: Bool
private let action: () -> Void
public init(
_ title: String,
systemImage: String,
tint: Color = AITheme.muted,
quiet: Bool = false,
action: @escaping () -> Void
) {
self.title = title
self.systemImage = systemImage
self.tint = tint
self.isQuiet = quiet
self.action = action
}
public var body: some View {
Button {
action()
} label: {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
.background(isQuiet ? Color.clear : tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous))
.overlay {
if isQuiet == false {
RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)
.strokeBorder(tint.opacity(0.18))
}
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
}
}
/// 按固有尺寸换行排布的流式布局:标签胶囊这类「宽度取决于文字」的控件用它。
/// 栅格会把每枚胶囊拉到同一列宽,短标签右侧就空出一片,读起来像没加载完。
public struct AIFlowLayout: Layout {
public let spacing: CGFloat
public init(spacing: CGFloat = 8) {
self.spacing = spacing
}
public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
widestRow = max(widestRow, x - spacing)
y += rowHeight + spacing
x = 0
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, x - spacing)
return CGSize(width: max(0, widestRow), height: max(0, y + rowHeight))
}
public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -0,0 +1,51 @@
import SwiftUI
struct AISkeletonLine: View {
let width: CGFloat?
let height: CGFloat
init(width: CGFloat? = nil, height: CGFloat) {
self.width = width
self.height = height
}
init(width fraction: CGFloat, height: CGFloat) {
self.init(width: nil, height: height)
self.fraction = fraction
}
private var fraction: CGFloat?
var body: some View {
Group {
if let fraction {
GeometryReader { proxy in
Capsule(style: .continuous)
.frame(width: max(0, proxy.size.width * fraction))
}
} else if let width {
Capsule(style: .continuous)
.frame(width: width)
}
}
.frame(height: height)
.background(AISkeletonFill())
}
}
struct AISkeletonCard: View {
let height: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.frame(height: height)
.background(AISkeletonFill())
}
}
private struct AISkeletonFill: View {
var body: some View {
Rectangle()
.fill(AITheme.control)
}
}
@@ -0,0 +1,152 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
private enum AIPalette {
#if canImport(UIKit)
private static func dynamicColor(light: UIColor, dark: UIColor) -> Color {
Color(
UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
private static func rgba(
_ red: CGFloat,
_ green: CGFloat,
_ blue: CGFloat,
_ alpha: CGFloat
) -> UIColor {
UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha)
}
private static func color(light: UIColor, dark: UIColor) -> Color {
dynamicColor(light: light, dark: dark)
}
// 中性石墨灰阶:所有中性色严格 R=G=B,既不冷到读出蓝,也不暖到读出米色;主色让位给
// 黑/白,颜色只用来表达状态。原先 B 比 R 高 3~10 级,浅色下大面积文字与描边会蒙一层冷调。
static let canvas = color(light: rgba(245, 245, 245, 1), dark: rgba(10, 10, 10, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(21, 21, 21, 1))
static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(29, 29, 29, 1))
static let control = color(light: rgba(239, 239, 239, 1), dark: rgba(39, 39, 39, 1))
static let tile = color(light: rgba(247, 247, 247, 1), dark: rgba(255, 255, 255, 0.055))
static let tileLine = color(light: rgba(232, 232, 232, 1), dark: rgba(255, 255, 255, 0.09))
static let chrome = color(light: rgba(252, 252, 252, 0.92), dark: rgba(13, 13, 13, 0.88))
static let ink = color(light: rgba(18, 18, 18, 1), dark: rgba(242, 242, 242, 1))
static let muted = color(light: rgba(92, 92, 92, 1), dark: rgba(164, 164, 164, 1))
static let faint = color(light: rgba(130, 130, 130, 1), dark: rgba(118, 118, 118, 1))
static let line = color(light: rgba(226, 226, 226, 1), dark: rgba(255, 255, 255, 0.11))
static let brand = color(light: rgba(18, 18, 18, 1), dark: rgba(9, 9, 9, 1))
static let primary = color(light: rgba(23, 23, 23, 1), dark: rgba(240, 240, 240, 1))
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(13, 13, 13, 1))
static let danger = color(light: rgba(193, 46, 40, 1), dark: rgba(247, 113, 105, 1))
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 8, 7, 1))
static let warning = color(light: rgba(170, 92, 15, 1), dark: rgba(233, 166, 60, 1))
static let success = color(light: rgba(16, 122, 86, 1), dark: rgba(66, 199, 132, 1))
static let segmentTrack = color(light: rgba(235, 235, 235, 1), dark: rgba(28, 28, 28, 1))
static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(58, 58, 58, 1))
static let cardShadow = color(light: rgba(18, 18, 18, 0.06), dark: rgba(0, 0, 0, 0.60))
#elseif canImport(AppKit)
static let canvas = Color(red: 0.961, green: 0.961, blue: 0.961)
static let surface = Color.white
static let control = Color(red: 0.937, green: 0.937, blue: 0.937)
static let ink = Color(red: 0.071, green: 0.071, blue: 0.071)
static let muted = Color(red: 0.361, green: 0.361, blue: 0.361)
static let line = Color(red: 0.886, green: 0.886, blue: 0.886)
static let brand = Color(red: 0.071, green: 0.071, blue: 0.071)
static let surfaceRaised = Color.white
static let chrome = Color.white.opacity(0.86)
static let onPrimary = Color.white
static let onDanger = Color.white
static let primary = Color(red: 0.090, green: 0.090, blue: 0.090)
static let danger = Color(red: 0.757, green: 0.180, blue: 0.157)
static let warning = Color(red: 0.667, green: 0.361, blue: 0.059)
static let success = Color(red: 0.063, green: 0.478, blue: 0.337)
static let tile = Color(red: 0.969, green: 0.969, blue: 0.969)
static let tileLine = Color(red: 0.910, green: 0.910, blue: 0.910)
static let faint = Color(red: 0.510, green: 0.510, blue: 0.510)
static let segmentTrack = Color(red: 0.922, green: 0.922, blue: 0.922)
static let segmentSelected = Color.white
static let cardShadow = Color(red: 0.071, green: 0.071, blue: 0.071).opacity(0.06)
#else
private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color {
Color(red: red / 255, green: green / 255, blue: blue / 255)
}
static let canvas = color(245, 245, 245)
static let surface = color(255, 255, 255)
static let control = color(239, 239, 239)
static let ink = color(18, 18, 18)
static let muted = color(92, 92, 92)
static let line = color(226, 226, 226)
static let brand = color(18, 18, 18)
static let surfaceRaised = color(255, 255, 255)
static let chrome = color(255, 255, 255, 0.82)
static let onPrimary = color(255, 255, 255)
static let onDanger = color(255, 255, 255)
static let primary = color(23, 23, 23)
static let danger = color(190, 65, 57)
static let warning = color(170, 111, 23)
static let success = color(50, 150, 101)
static let tile = color(247, 247, 247)
static let tileLine = color(232, 232, 232)
static let faint = color(130, 130, 130)
static let segmentTrack = color(235, 235, 235)
static let segmentSelected = color(255, 255, 255)
static let cardShadow = color(18, 18, 18)
#endif
}
public enum AITheme {
public static let canvas = AIPalette.canvas
public static let surface = AIPalette.surface
public static let surfaceRaised = AIPalette.surfaceRaised
public static let control = AIPalette.control
public static let tile = AIPalette.tile
public static let tileLine = AIPalette.tileLine
public static let chrome = AIPalette.chrome
public static let ink = AIPalette.ink
public static let muted = AIPalette.muted
public static let faint = AIPalette.faint
public static let line = AIPalette.line
public static let brand = AIPalette.brand
public static let primary = AIPalette.primary
public static let onPrimary = AIPalette.onPrimary
public static let danger = AIPalette.danger
public static let onDanger = AIPalette.onDanger
public static let warning = AIPalette.warning
public static let success = AIPalette.success
public static let segmentTrack = AIPalette.segmentTrack
public static let segmentSelected = AIPalette.segmentSelected
public static let cardShadow = AIPalette.cardShadow
public static let buttonHeight: CGFloat = 48
// 停用态主按钮保留一层主色淡底,避免和次级按钮长得一模一样,层级读不出来。
public static let disabledFillOpacity: Double = 0.14
public static let disabledStrokeOpacity: Double = 0.22
public static let compactButtonHeight: CGFloat = 44
public static let largeButtonHeight: CGFloat = 56
public static let toolbarButtonHeight: CGFloat = 44
// 导航栏只给约 38pt 高度,44pt 的方块会被上下裁切,所以工具栏图标按钮单独取 34。
public static let iconButtonSize: CGFloat = 34
public static let iconButtonRadius: CGFloat = 11
public static let buttonRadius: CGFloat = 16
public static let cardRadius: CGFloat = 22
public static let inputRadius: CGFloat = 16
public static let inputHeight: CGFloat = 52
public static let segmentedControlHeight: CGFloat = 44
public static let tileRadius: CGFloat = 14
public static let chipHeight: CGFloat = 34
public static let compactControlHeight: CGFloat = 44
public static let compactButtonMinWidth: CGFloat = 72
public static let regularButtonMinWidth: CGFloat = 88
public static let toolbarButtonMinWidth: CGFloat = 96
public static let toolbarButtonWidth: CGFloat = 104
public static let footerSecondaryButtonWidth: CGFloat = 104
public static let listActionButtonWidth: CGFloat = 84
}
@@ -0,0 +1,56 @@
import Foundation
import SwiftUI
public enum ThemeMode: String, CaseIterable, Identifiable, Codable, Sendable {
case system
case light
case dark
public var id: String { rawValue }
public var label: String {
switch self {
case .system: return "跟随系统"
case .light: return "浅色"
case .dark: return "深色"
}
}
public var systemImage: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon.stars"
}
}
}
@MainActor
final class ThemeStore: ObservableObject {
static let shared = ThemeStore()
@Published private(set) var mode: ThemeMode
private let defaults: UserDefaults
private static let storageKey = "aiThemeMode"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
mode = ThemeMode(rawValue: defaults.string(forKey: Self.storageKey) ?? "") ?? .system
}
func set(_ newMode: ThemeMode) {
guard newMode != mode else { return }
mode = newMode
defaults.set(newMode.rawValue, forKey: Self.storageKey)
}
}
extension ThemeMode {
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
@@ -0,0 +1,20 @@
import Foundation
public actor InstanceSessionStore {
private var cookies: [UUID: (origin: URL, cookie: String)] = [:]
public init() {}
public func cookie(for id: UUID, origin: URL) -> String? {
guard let value = cookies[id], value.origin == origin else { return nil }
return value.cookie
}
public func set(_ id: UUID, origin: URL, cookie: String) {
cookies[id] = (origin, cookie)
}
public func clear(_ id: UUID) {
cookies.removeValue(forKey: id)
}
}
@@ -0,0 +1,71 @@
import Foundation
public struct UpstreamResponse: Equatable, Sendable {
public let status: Int
public let data: Data
public let setCookie: String?
public init(status: Int, data: Data, setCookie: String? = nil) {
self.status = status
self.data = data
self.setCookie = setCookie
}
}
public struct SimAdminClient: Sendable {
private let session: URLSession
private let maximumBytes: Int
public init(session: URLSession = .shared, maximumBytes: Int = 262_144) {
self.session = session
self.maximumBytes = maximumBytes
}
public func send(
_ route: UpstreamRoute,
baseURL: URL,
cookie: String?
) async throws -> UpstreamResponse {
guard baseURL.scheme == "http" || baseURL.scheme == "https",
baseURL.path.isEmpty || baseURL.path == "/"
else {
throw UpstreamError.invalidRequest
}
guard let url = URL(string: baseURL.absoluteString + route.path) else {
throw UpstreamError.invalidRequest
}
var request = URLRequest(url: url, timeoutInterval: 30)
request.httpMethod = route.method
request.httpBody = route.body
request.setValue("application/json", forHTTPHeaderField: "accept")
if route.body != nil {
request.setValue("application/json", forHTTPHeaderField: "content-type")
}
if let cookie {
request.setValue(cookie, forHTTPHeaderField: "cookie")
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, data.count <= maximumBytes else {
throw UpstreamError.invalidResponse
}
switch http.statusCode {
case 200...299:
break
case 401: throw UpstreamError.unauthorized
case 403: throw UpstreamError.forbidden
case 404, 405: throw UpstreamError.notSupported
default: throw UpstreamError.transportFailed
}
let setCookie = http.allHeaderFields["Set-Cookie"] as? String
return UpstreamResponse(status: http.statusCode, data: data, setCookie: setCookie)
} catch let error as UpstreamError {
throw error
} catch let error as URLError where error.code == .timedOut {
throw UpstreamError.timeout
} catch {
throw UpstreamError.transportFailed
}
}
}
@@ -0,0 +1,132 @@
import Foundation
public enum UpstreamRoute: Hashable, Sendable {
case health
case device
case sim
case network
case signalStrength
case stats
case statsCpu
case connectivity
case smsList(limit: Int, offset: Int, direction: String?)
case smsSend(phoneNumber: String, content: String)
case smsBatchDelete(ids: [Int])
case authLogin(password: String)
case authLogout
public var method: String {
switch self {
case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu,
.connectivity, .smsList:
return "GET"
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
return "POST"
}
}
public var path: String {
switch self {
case .health: return "/api/health"
case .device: return "/api/device"
case .sim: return "/api/sim"
case .network: return "/api/network"
case .signalStrength: return "/api/network/signal-strength"
case .stats: return "/api/stats"
case .statsCpu: return "/api/stats/cpu"
case .connectivity: return "/api/connectivity"
case .smsList(let limit, let offset, let direction):
var items = ["limit=\(limit)", "offset=\(offset)"]
if let direction {
items.append("direction=\(direction)")
}
return "/api/sms/list?\(items.joined(separator: "&"))"
case .smsSend: return "/api/sms/send"
case .smsBatchDelete: return "/api/sms/batch-delete"
case .authLogin: return "/api/auth/login"
case .authLogout: return "/api/auth/logout"
}
}
public var body: Data? {
switch self {
case .smsSend(let phoneNumber, let content):
return try? JSONSerialization.data(
withJSONObject: ["phone_number": phoneNumber, "content": content]
)
case .smsBatchDelete(let ids):
return try? JSONSerialization.data(withJSONObject: ["ids": ids])
case .authLogin(let password):
return try? JSONSerialization.data(withJSONObject: ["password": password])
default:
return nil
}
}
/// Whether the node's agent will demand a confirmation intent for this route.
///
/// This is the app's copy of `policy.dangerous` in `contracts/agent-policy.json`, and the two
/// are asserted equal by the relay repository's policy test. It is a copy rather than a shared
/// file because the app cannot read the relay's repository at build time; the test is what
/// keeps it from drifting.
public var requiresConfirmation: Bool {
switch self {
case .smsSend, .smsBatchDelete:
return true
default:
return false
}
}
}
public enum UpstreamError: Error, Equatable {
case invalidRequest
case unauthorized
case forbidden
case notSupported
case timeout
case transportFailed
case invalidResponse
// Relay-mode failures. These are deliberately separate cases rather than a reuse of
// transportFailed: in relay mode the user has four genuinely different situations to tell
// apart, and a single "请求失败" for all of them is what makes a two-hop architecture
// impossible to operate.
/// The relay could not be reached at all.
case relayUnreachable
/// The relay took too long to answer.
case relayTimeout
/// The relay rejected this phone's bearer token: the pairing is no longer valid there.
case relayRejectedDevice
/// The relay does not know this node, or this phone is not paired to it.
case relayNodeUnknown
/// The node's agent is not connected to the relay.
case agentOffline
/// The node is connected but already has the maximum number of requests in flight.
case nodeBusy
/// The node accepted the request but did not answer within the relay's deadline. It may or may
/// not have executed it, which is why the message must not invite a blind retry.
case agentTimeout
/// The relay refused the envelope before delivering it.
case relayBadEnvelope
/// The relay is rate limiting this phone.
case relayRateLimited
/// The node's agent rejected the request: not on its allowlist, or the device is not paired.
case relayPolicyDenied
/// The node received the request and the confirmation intent did not match.
case confirmationRejected
/// The envelope was outside the node's freshness window, or its id had already been used.
case envelopeExpired
/// The node's own rate limit refused a dangerous write.
case nodeRateLimited
/// The node's agent could not authenticate to its local SimAdmin.
case nodeAuthFailed
/// The request body exceeded the node's limit.
case payloadTooLarge
/// The node signed nothing, or the answer was not for this request.
case relayResponseUnverifiable
/// The relay answered about a different request or node.
case relayResponseMismatch
/// The node refused the envelope's signature or shape.
case envelopeRejected
}
@@ -0,0 +1,103 @@
import SwiftUI
import XCTest
@testable import MultiSimAdmin
final class AIThemeTests: XCTestCase {
func testControlDimensionsMatchIOS() {
XCTAssertEqual(AITheme.buttonHeight, 48, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 56, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 34, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonRadius, 11, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 96, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonWidth, 104, accuracy: 0.1)
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 104, accuracy: 0.1)
XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1)
XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01)
}
func testBottomActionsFillEqualWidths() {
let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)
let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)
XCTAssertTrue(primary.fillsWidth)
XCTAssertTrue(secondary.fillsWidth)
}
func testFixedWidthDrawsInsideTheButtonStyle() {
let button = AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
XCTAssertTrue(button.fillsWidth == false)
XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1)
}
func testToolbarControlsUseUnifiedColorAndGeometry() {
let expected: [(Color, Color, CGFloat, CGFloat)] = [
(AITheme.control, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.primary, AITheme.onPrimary, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.danger, AITheme.onDanger, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
]
for (background, foreground, width, height) in expected {
XCTAssertTrue(background == background)
XCTAssertTrue(foreground == foreground)
XCTAssertEqual(width, 104, accuracy: 0.1)
XCTAssertEqual(height, 44, accuracy: 0.1)
}
}
func testButtonKindsHaveDistinctBaseColors() {
let colors = [
AIButtonStyle.Kind.primary,
.secondary,
.danger,
].map(\.backgroundColor)
XCTAssertEqual(Set(colors).count, 3)
}
func testSearchFieldExposesClearAction() throws {
let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理")))
let text = try XCTUnwrap(
mirror.children.first { $0.label == "_text" }?.value as? Binding<String>)
XCTAssertEqual(text.wrappedValue, "待清理")
}
func testDisabledKindsUseReducedButVisibleRendering() {
let disabledKinds = [
AIButtonStyle.Kind.primary,
.secondary,
.danger,
]
let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) }
// 停用态仍然要保住按钮层级:主按钮留一层主色淡底,次级/危险退回中性 control。
XCTAssertEqual(disabledColors.count, 3)
XCTAssertEqual(disabledColors[1], AITheme.control)
XCTAssertEqual(disabledColors[2], AITheme.control)
XCTAssertTrue(disabledColors[0] != AITheme.control)
// 只有实心主色按钮需要"同色相淡填充"处理,灰底次按钮不能套,否则两者又会长一样。
XCTAssertEqual(
[
AIButtonStyle.Kind.primary,
.secondary,
.danger,
].map(\.usesTintedDisabledTreatment),
[true, false, false]
)
XCTAssertEqual(
disabledKinds.map { $0.foregroundColor(isEnabled: false) },
[
AITheme.muted,
AITheme.muted,
AITheme.muted,
]
)
}
}
@@ -0,0 +1,12 @@
import XCTest
@testable import MultiSimAdmin
final class AppMetadataTests: XCTestCase {
func testMetadataUsesIPAFoundationContract() {
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.2")
XCTAssertEqual(AppMetadata.buildVersion, "10")
}
}
@@ -0,0 +1,142 @@
import XCTest
@testable import MultiSimAdmin
/// Keeps `contracts/app-routes.json` honest.
///
/// That file is the relay repository's copy of every upstream path this app can construct, and the
/// agent's authorisation policy is pinned to it: `test/policy.test.js` asserts the two sets are
/// equal, so the agent can never be wider than the app. The missing link was always the other
/// direction — nothing checked that the vendored file still matched `UpstreamRoute`, and a route
/// added here without updating it would leave the agent narrower than the app, which fails at
/// runtime as "policy_denied" for an operation the UI offers.
///
/// So the check lives here, next to the source of truth, and the vendored file carries a snapshot.
final class AppRoutesContractTests: XCTestCase {
/// Every route the app can construct, in the vendored file's shape.
///
/// Written out by hand rather than derived, because deriving it from `UpstreamRoute` would make
/// the test tautological: it would compare the enum against itself. This list is the claim about
/// what the app can ask for, and adding a case to the enum without adding it here fails the
/// count assertion below.
private static let declaredRoutes: [(method: String, path: String)] = [
("GET", "/api/health"),
("GET", "/api/device"),
("GET", "/api/sim"),
("GET", "/api/network"),
("GET", "/api/network/signal-strength"),
("GET", "/api/stats"),
("GET", "/api/stats/cpu"),
("GET", "/api/connectivity"),
("GET", "/api/sms/list"),
("POST", "/api/sms/send"),
("POST", "/api/sms/batch-delete"),
]
private static let authRoutes: [(method: String, path: String)] = [
("POST", "/api/auth/login"),
("POST", "/api/auth/logout"),
]
private static let candidatePaths = [
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
].compactMap { $0 }
private func loadAppRoutes() throws -> [String: Any] {
for candidate in Self.candidatePaths {
let routes = URL(fileURLWithPath: candidate)
.deletingLastPathComponent()
.appendingPathComponent("app-routes.json")
guard FileManager.default.fileExists(atPath: routes.path) else { continue }
let data = try Data(contentsOf: routes)
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XCTSkip("app-routes.json is not a JSON object")
}
return object
}
throw XCTSkip("simadmin-relay/contracts/app-routes.json not found; set MSA_RELAY_VECTORS")
}
/// Each route in the declared list must produce the path and method it claims. This is what
/// catches a path edited in `UpstreamRoute` without the snapshot being updated.
func testEveryDeclaredRouteMatchesTheEnum() throws {
for (method, expectedPath) in Self.declaredRoutes {
let route = try XCTUnwrap(
Self.sampleRoute(matching: expectedPath),
"no UpstreamRoute case produces \(expectedPath)"
)
XCTAssertEqual(route.method, method, "method for \(expectedPath)")
// Query strings are part of the path the agent forwards, so compare the prefix the
// route's own literal contributes.
XCTAssertTrue(
route.path.hasPrefix(expectedPath),
"route for \(expectedPath) produced \(route.path)"
)
}
}
func testAuthRoutesAreNotProxied() throws {
let routes = try loadAppRoutes()
let auth = try XCTUnwrap(routes["auth"] as? [String])
let declared = Set(Self.authRoutes.map(\.path))
XCTAssertEqual(Set(auth), declared, "the vendored auth list drifted")
// The agent must do the logging in itself, so these paths must never appear in the proxied
// set — that is what keeps a node password from ever traversing the relay.
let proxied = Set(Self.declaredRoutes.map(\.path))
XCTAssertTrue(proxied.isDisjoint(with: declared))
}
func testTheVendoredFileMatchesThisList() throws {
let routes = try loadAppRoutes()
let vendored = try XCTUnwrap(routes["routes"] as? [[String: String]])
let vendoredPairs = Set(vendored.compactMap { entry -> String? in
guard let method = entry["method"], let path = entry["path"] else { return nil }
return "\(method) \(path)"
})
let declaredPairs = Set(Self.declaredRoutes.map { "\($0.method) \($0.path)" })
XCTAssertEqual(
vendoredPairs.symmetricDifference(declaredPairs),
[],
"app-routes.json drifted from the app's route surface; regenerate it deliberately"
)
}
/// The app's copy of `policy.dangerous` must match the agent's, or a write would either be
/// refused for a missing intent or would carry an intent the agent does not require.
func testConfirmationIsRequiredExactlyWhereTheAgentRequiresIt() throws {
let routes = try loadAppRoutes()
// The agent policy lives beside the app routes; the relay repository's policy test already
// asserts the two files agree, so reading the app-routes list here is enough.
for (method, path) in Self.declaredRoutes {
let route = try XCTUnwrap(Self.sampleRoute(matching: path))
let isWrite = method == "POST" && path.hasPrefix("/api/sms/")
XCTAssertEqual(
route.requiresConfirmation,
isWrite,
"confirmation intent flag for \(method) \(path)"
)
}
}
/// Builds the representative `UpstreamRoute` for a path. The sample arguments are arbitrary:
/// what is under test is the path and method mapping, not the query values.
private static func sampleRoute(matching path: String) -> UpstreamRoute? {
switch path {
case "/api/health": return .health
case "/api/device": return .device
case "/api/sim": return .sim
case "/api/network": return .network
case "/api/network/signal-strength": return .signalStrength
case "/api/stats": return .stats
case "/api/stats/cpu": return .statsCpu
case "/api/connectivity": return .connectivity
case "/api/sms/list": return .smsList(limit: 50, offset: 0, direction: nil)
case "/api/sms/send": return .smsSend(phoneNumber: "+8613800000000", content: "hi")
case "/api/sms/batch-delete": return .smsBatchDelete(ids: [1])
default: return nil
}
}
}
@@ -0,0 +1,44 @@
import XCTest
@testable import MultiSimAdmin
final class ConnectionProbeTests: XCTestCase {
func testProbeMapsUpstreamResults() async {
let unauthorized = ConnectionProbe(client: MockClient(status: 401))
let online = ConnectionProbe(client: MockClient(status: 200))
let unavailable = ConnectionProbe(client: MockClient(status: 0))
let instance = Self.instance()
let auth = try? await unauthorized.test(instance)
let reachable = try? await online.test(instance)
let offline = try? await unavailable.test(instance)
XCTAssertEqual(auth?.status, .authRequired)
XCTAssertEqual(reachable?.status, .online)
XCTAssertEqual(offline?.status, .offline)
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Probe",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
credentialConfigured: false,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
private struct MockClient: SimAdminClientProtocol {
let status: Int
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
if status == 0 {
throw UpstreamError.timeout
}
return UpstreamResponse(status: status, data: Data("{}".utf8))
}
}
}
@@ -0,0 +1,80 @@
import XCTest
@testable import MultiSimAdmin
final class DeviceServiceTests: XCTestCase {
func testLoadsAndFlattensWhitelistedReadSections() async throws {
let store = InstanceSessionStore()
let client = DeviceTestClient()
let service = DeviceService(client: client, sessions: store)
let instance = Self.instance()
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
let snapshot = try await service.snapshot(instance)
XCTAssertEqual(client.routes, [.device, .sim, .network, .stats, .statsCpu, .connectivity])
XCTAssertEqual(snapshot.sections[0].values["model"], "SIMBox 8")
XCTAssertEqual(snapshot.sections[0].values["online"], "是")
XCTAssertEqual(snapshot.sections[1].values["phone_numbers"], "13800138000")
XCTAssertEqual(snapshot.sections[2].values["operator_name"], "中国移动")
XCTAssertEqual(snapshot.sections[3].values["uptime_uptime_seconds"], "86400")
XCTAssertEqual(snapshot.sections[4].values["core_count"], "4")
XCTAssertEqual(snapshot.sections[5].values["state"], "connected")
}
func testBuildsRunningSummaryFromUpstreamSections() async throws {
let store = InstanceSessionStore()
let client = DeviceTestClient()
let service = DeviceService(client: client, sessions: store)
let instance = Self.instance()
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
let summary = try await service.summary(instance)
XCTAssertEqual(summary.deviceName, "SIMBox 8")
XCTAssertEqual(summary.phoneNumber, "13800138000")
XCTAssertEqual(summary.operatorName, "中国移动")
XCTAssertEqual(summary.connectivity, "connected")
XCTAssertEqual(summary.firmwareVersion, "2.4.1")
XCTAssertEqual(summary.totalStorageBytes, 1024)
XCTAssertEqual(summary.availableStorageBytes, 512)
XCTAssertEqual(summary.downloadRateBytesPerSecond, 128)
XCTAssertEqual(summary.uploadRateBytesPerSecond, 64)
XCTAssertEqual(summary.messagesToday, 12)
XCTAssertEqual(summary.uptimeSeconds, 86400)
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Device Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private final class DeviceTestClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
guard cookie == "simadmin_session=token" else {
throw UpstreamError.unauthorized
}
let payload: [UpstreamRoute: String] = [
.device: #"{"data":{"model":"SIMBox 8","online":true,"firmware_version":"2.4.1"}}"#,
.sim: #"{"data":{"phone_numbers":["13800138000"],"iccid":"123"}}"#,
.network: #"{"data":{"operator_name":"中国移动","registration_status":"registered"}}"#,
.stats: #"{"data":{"uptime":{"uptime_seconds":86400},"disk_total_bytes":1024,"disk_available_bytes":512}}"#,
.statsCpu: #"{"data":{"core_count":4,"model_name":"ARM CPU"}}"#,
.connectivity: #"{"data":{"state":"connected","latency_ms":21,"download":128,"upload":64,"messages_today":12}}"#
]
guard let json = payload[route] else {
throw UpstreamError.notSupported
}
return UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -0,0 +1,14 @@
import SwiftUI
import XCTest
@testable import MultiSimAdmin
final class ErrorMessageTests: XCTestCase {
func testNetworkFailureUsesPlainActionableCopy() {
XCTAssertEqual(ErrorMessage.network, "暂时无法连接实例,请稍后重试。")
}
func testUnauthorizedFailureUsesLoginGuidance() {
XCTAssertEqual(ErrorMessage.unauthorized, "实例登录状态已失效,请重新登录。")
}
}
@@ -0,0 +1,32 @@
import XCTest
@testable import MultiSimAdmin
final class FileCredentialStoreTests: XCTestCase {
func testSavesReadsAndRemovesPasswordInSandboxFile() throws {
let location = FileManager.default.temporaryDirectory
.appendingPathComponent("credential-store-\(UUID().uuidString).json")
let store = FileCredentialStore(location: location)
let instanceID = UUID()
XCTAssertNil(try store.get(instanceID: instanceID))
try store.set(instanceID: instanceID, password: "sandbox-secret")
XCTAssertEqual(try store.get(instanceID: instanceID), "sandbox-secret")
XCTAssertTrue(FileManager.default.fileExists(atPath: location.path))
try store.remove(instanceID: instanceID)
XCTAssertNil(try store.get(instanceID: instanceID))
}
func testCredentialFileIsPrivate() throws {
let location = FileManager.default.temporaryDirectory
.appendingPathComponent("credential-store-\(UUID().uuidString).json")
let store = FileCredentialStore(location: location)
try store.set(instanceID: UUID(), password: "private-secret")
let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
XCTAssertEqual(attributes[.posixPermissions] as? Int, 0o600)
}
}
@@ -0,0 +1,355 @@
import XCTest
@testable import MultiSimAdmin
@MainActor
final class FleetViewModelTests: XCTestCase {
private func transientPreferences() -> UserDefaults {
let suite = "FleetViewModelTests.\(UUID().uuidString)"
return UserDefaults(suiteName: suite)!
}
func testRefreshSortsByNameAndReportsStatus() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Zulu",
origin: URL(string: "http://192.168.1.30:3000")!,
tags: ["lab"],
password: nil
))
try await model.add(InstanceInput(
name: "Alpha",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil })
}
func testVisibleRowsFollowSearchAndTagFilters() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"]))
try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: []))
await model.refresh()
model.searchText = "home"
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
model.searchText = ""
model.clearTagFilters()
model.toggleTagFilter("Home")
XCTAssertEqual(model.allTags, ["home"])
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
XCTAssertEqual(model.countForTagFilter("home"), 1)
model.clearTagFilters()
XCTAssertEqual(model.visibleRows.count, 2, "没有筛选时全部节点都要出现")
}
func testVisibleRowsPutProblemNodesFirst() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: UnreachableForClient(host: "192.168.1.99")),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Healthy", origin: URL(string: "http://192.168.1.98:3000")!))
try await model.add(InstanceInput(name: "Broken", origin: URL(string: "http://192.168.1.99:3000")!))
await model.refresh()
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Broken", "Healthy"])
}
func testTagFiltersCombineWithAndSemanticsAndPruneStaleSelections() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Both",
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["primary", "field"]
))
try await model.add(InstanceInput(
name: "Single",
origin: URL(string: "http://192.168.1.91:3000")!,
tags: ["primary"]
))
await model.refresh()
model.toggleTagFilter("primary")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both", "Single"])
model.toggleTagFilter("FIELD")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both"])
model.toggleTagFilter("field")
model.toggleTagFilter("primary")
XCTAssertTrue(model.selectedTagFilters.isEmpty)
model.toggleTagFilter("vanished")
XCTAssertTrue(model.isTagFilterActive("vanished"))
await model.refresh()
XCTAssertTrue(model.selectedTagFilters.isEmpty, "已不存在的标签筛选应当在刷新后自动清理")
}
func testTagFilterSelectionSurvivesAcrossInstances() async throws {
let suite = "FleetTagFilterTests.\(UUID().uuidString)"
let repository = MemoryInstanceRepository()
let first = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
try await first.add(InstanceInput(
name: "Relay",
origin: URL(string: "http://192.168.1.92:3000")!,
tags: ["relay"]
))
await first.refresh()
first.toggleTagFilter("relay")
let second = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
XCTAssertTrue(second.selectedTagFilters.contains("relay"))
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
func testUpdateRewritesNameAndTags() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Van",
origin: URL(string: "http://192.168.1.80:3000")!,
tags: ["old"]
))
await model.refresh()
let row = try XCTUnwrap(model.rows.first)
try await model.update(row.id, InstanceInput(
name: "Van Pro",
endpoint: row.instance.endpoint,
tags: ["field"]
))
XCTAssertEqual(model.rows.first?.instance.name, "Van Pro")
XCTAssertEqual(model.rows.first?.instance.tags, ["field"])
}
func testApplyTagAddsAndRemovesAcrossTheWholeSelection() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
let first = try await model.add(InstanceInput(
name: "First",
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["keep"]
))
let second = try await model.add(InstanceInput(
name: "Second",
origin: URL(string: "http://192.168.1.91:3000")!
))
await model.refresh()
let ids = [first.id, second.id]
await model.applyTag("外场", to: ids)
XCTAssertEqual(model.rows.map(\.instance.tags).map { $0.contains("外场") }, [true, true])
XCTAssertEqual(model.tagCoverage("外场", in: ids), 2)
// 再点一次只撕掉这一个标签,其他标签必须留下。
await model.applyTag("外场", to: [first.id], removing: true)
XCTAssertEqual(model.rows.first?.instance.tags, ["keep"])
XCTAssertEqual(model.tagCoverage("外场", in: ids), 1)
}
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Secret",
origin: URL(string: "http://192.168.1.40:3000")!,
password: "pass"
))
let row = try XCTUnwrap(model.rows.first)
XCTAssertTrue(row.instance.credentialConfigured)
XCTAssertEqual(credentials.saved[row.instance.id], "pass")
await model.delete([row.instance.id])
XCTAssertTrue(model.rows.isEmpty)
XCTAssertNil(credentials.saved[row.instance.id])
}
func testDeleteSelectedClearsSelectionAndCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Selected",
origin: URL(string: "http://192.168.1.50:3000")!,
password: "pass"
))
try await model.add(InstanceInput(
name: "Other",
origin: URL(string: "http://192.168.1.60:3000")!,
password: nil
))
let selectedID = model.rows[0].id
model.selectionMode = true
model.selectedIDs = [selectedID]
await model.deleteSelected()
XCTAssertEqual(model.rows.map(\.instance.name), ["Selected"])
XCTAssertEqual(credentials.removed, [selectedID])
XCTAssertFalse(model.selectionMode)
XCTAssertTrue(model.selectedIDs.isEmpty)
}
}
struct AlwaysOnlineClient: SimAdminClientProtocol {
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
/// 指定主机一律不可达,用来验证"问题节点排在前面"。
struct UnreachableForClient: SimAdminClientProtocol {
let host: String
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
if baseURL.host == host {
throw UpstreamError.transportFailed
}
return UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
final class NoopCredentialStore: @unchecked Sendable, CredentialStore {
private let lock = NSLock()
private var savedStorage: [UUID: String] = [:]
private var removedStorage: [UUID] = []
var saved: [UUID: String] {
lock.withLock { savedStorage }
}
var removed: [UUID] {
lock.withLock { removedStorage }
}
func get(instanceID: UUID) throws -> String? {
saved[instanceID]
}
func set(instanceID: UUID, password: String) throws {
lock.withLock { savedStorage[instanceID] = password }
}
func remove(instanceID: UUID) throws {
lock.withLock {
savedStorage[instanceID] = nil
removedStorage.append(instanceID)
}
}
}
final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
private let lock = NSLock()
private var itemsStorage: [Instance] = []
private var items: [Instance] {
get { lock.withLock { itemsStorage } }
set { lock.withLock { itemsStorage = newValue } }
}
func list(_: InstanceQuery) async throws -> [Instance] {
items.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
func get(_ id: UUID) async throws -> Instance? {
items.first { $0.id == id }
}
func create(_ input: InstanceInput) async throws -> Instance {
let now = Date()
let instance = Instance(
id: UUID(),
name: input.name,
endpoint: input.endpoint,
tags: input.tags,
credentialConfigured: !(input.password ?? "").isEmpty,
configRevision: 1,
createdAt: now,
updatedAt: now
)
items.append(instance)
return instance
}
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
guard let index = items.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard items[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
var instance = items[index]
if let name = patch.name { instance.name = name }
if let endpoint = patch.endpoint { instance.endpoint = endpoint }
if let tags = patch.tags { instance.tags = tags }
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}
instance.updatedAt = Date()
instance.configRevision += 1
items[index] = instance
return instance
}
func delete(_ id: UUID, expectedRevision: Int) async throws {
items.removeAll { $0.id == id }
}
}
@@ -0,0 +1,225 @@
import CryptoKit
import XCTest
@testable import MultiSimAdmin
/// The upgrade path from a node list written before relay support existed.
///
/// This is the riskiest change in the iOS work: `Instance.origin` was non-optional and carried three
/// responsibilities at once, and a decoder that mishandles the old shape does not fail one screen —
/// it fails the whole store, and every node the user ever added disappears. So the old JSON is
/// pinned literally here rather than produced by an encoder that might itself have drifted.
final class InstanceEndpointMigrationTests: XCTestCase {
private var location: URL!
override func setUp() {
super.setUp()
location = FileManager.default.temporaryDirectory
.appendingPathComponent("msa-migration-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: location)
super.tearDown()
}
/// Exactly the shape the previous release wrote: `origin` as a string, no `endpoint` key.
private func legacyStore() -> Data {
Data("""
{
"instances" : [
{
"configRevision" : 3,
"createdAt" : "2026-08-01T10:00:00Z",
"credentialConfigured" : true,
"id" : "11111111-2222-4333-8444-555555555555",
"name" : "Lab Alpha",
"origin" : "http://192.168.3.55:3000",
"tags" : ["lab"],
"updatedAt" : "2026-08-02T10:00:00Z"
}
]
}
""".utf8)
}
func testLegacyOriginBecomesADirectEndpoint() async throws {
try legacyStore().write(to: location)
let repository = FileInstanceRepository(location: location)
let instances = try await repository.list(InstanceQuery())
XCTAssertEqual(instances.count, 1, "a legacy store must not lose nodes")
let instance = try XCTUnwrap(instances.first)
XCTAssertEqual(instance.endpoint, .direct(URL(string: "http://192.168.3.55:3000")!))
XCTAssertEqual(instance.endpoint.directOrigin?.absoluteString, "http://192.168.3.55:3000")
XCTAssertFalse(instance.endpoint.isRelay)
// Everything else must survive the migration untouched.
XCTAssertEqual(instance.name, "Lab Alpha")
XCTAssertEqual(instance.tags, ["lab"])
XCTAssertEqual(instance.configRevision, 3)
XCTAssertTrue(instance.credentialConfigured)
}
/// An upgraded node must not be reported as a duplicate of itself, which is what would happen if
/// the dedup key changed shape between the read and the write.
func testAnUpgradedLegacyNodeIsNotADuplicateOfItself() async throws {
try legacyStore().write(to: location)
let repository = FileInstanceRepository(location: location)
let listed = try await repository.list(InstanceQuery())
let existing = try XCTUnwrap(listed.first)
// Re-saving the same address must still be refused...
do {
_ = try await repository.create(
InstanceInput(name: "Again", origin: URL(string: "http://192.168.3.55:3000")!)
)
XCTFail("a duplicate origin was accepted")
} catch let error as RepositoryError {
XCTAssertEqual(error, .duplicateOrigin)
}
// ...but updating the node itself must succeed.
let updated = try await repository.update(
existing.id,
expectedRevision: existing.configRevision,
InstancePatch(name: "Renamed")
)
XCTAssertEqual(updated.name, "Renamed")
XCTAssertEqual(updated.endpoint, existing.endpoint)
}
/// After any write, the file carries the new shape and no `origin` key. Leaving both would make
/// the old field a second source of truth for the same address.
func testWritesEmitOnlyTheNewShape() async throws {
try legacyStore().write(to: location)
let repository = FileInstanceRepository(location: location)
let listed = try await repository.list(InstanceQuery())
let existing = try XCTUnwrap(listed.first)
_ = try await repository.update(
existing.id,
expectedRevision: existing.configRevision,
InstancePatch(tags: ["lab", "primary"])
)
let raw = try String(contentsOf: location, encoding: .utf8)
XCTAssertTrue(raw.contains("\"endpoint\""), "the new shape must be written")
XCTAssertFalse(raw.contains("\"origin\""), "the legacy key must not be written back")
}
/// The two dedup keyspaces must not collide: a direct node at some address and a relay node are
/// different nodes even if a hostname happened to match.
func testRelayAndDirectEndpointsAreDistinctIdentities() async throws {
let repository = FileInstanceRepository(location: location)
_ = try await repository.create(
InstanceInput(name: "Direct", origin: URL(string: "https://relay.example")!)
)
let relay = try await repository.create(InstanceInput(
name: "Relayed",
endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
))
XCTAssertEqual(relay.endpoint.dedupKey, "relay:nd_7f3a9c2e")
let all = try await repository.list(InstanceQuery())
XCTAssertEqual(all.count, 2)
// The same node id from the same relay is the same instance, so it is refused.
do {
_ = try await repository.create(InstanceInput(
name: "Again",
endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
))
XCTFail("the same relay node was added twice")
} catch let error as RepositoryError {
XCTAssertEqual(error, .duplicateOrigin)
}
}
/// A half-filled relay endpoint must be refused at the repository rather than accepted and then
/// failing in the transport as an unreadable response, which is indistinguishable to the user
/// from "the node is broken".
func testAnIncompleteRelayEndpointIsRefused() async throws {
let repository = FileInstanceRepository(location: location)
var cases: [RelayEndpoint] = []
var wrongKeySize = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
wrongKeySize.agentSigningKey = Data(repeating: 1, count: 31)
cases.append(wrongKeySize)
var plainHTTP = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
plainHTTP.relayBaseURL = URL(string: "http://relay.example")!
cases.append(plainHTTP)
var badNodeID = Self.relayEndpoint(nodeID: "node-1")
badNodeID.nodeID = "node-1"
cases.append(badNodeID)
var badDeviceID = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
badDeviceID.deviceID = "phone"
cases.append(badDeviceID)
var shortToken = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
shortToken.deviceToken = "abc"
cases.append(shortToken)
for (index, endpoint) in cases.enumerated() {
do {
_ = try await repository.create(
InstanceInput(name: "Broken \(index)", endpoint: .relay(endpoint))
)
XCTFail("incomplete relay endpoint \(index) was accepted")
} catch let error as RepositoryError {
XCTAssertEqual(error, .invalidOrigin)
}
}
}
func testRelayEndpointRoundTripsThroughTheStore() async throws {
let repository = FileInstanceRepository(location: location)
let endpoint = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
let created = try await repository.create(
InstanceInput(name: "Relayed", endpoint: .relay(endpoint), tags: ["remote"])
)
// A second repository over the same file proves the bytes on disk are what decode, not an
// in-memory copy.
let reopened = FileInstanceRepository(location: location)
let fetched = try await reopened.get(created.id)
let loaded = try XCTUnwrap(fetched)
XCTAssertEqual(loaded.endpoint, .relay(endpoint))
XCTAssertEqual(loaded.endpoint.relay?.agentSigningKey, endpoint.agentSigningKey)
XCTAssertEqual(loaded.endpoint.relay?.agentKxKey, endpoint.agentKxKey)
XCTAssertEqual(loaded.endpoint.relay?.deviceToken, endpoint.deviceToken)
XCTAssertEqual(loaded.endpoint.relay?.nodeName, "Lab Alpha")
}
/// The two modes must not share a credential string: in relay mode the phone holds no node
/// password, and saying otherwise is a false statement about where the secret lives.
func testCredentialWordingDiffersByMode() {
let direct = InstanceEndpoint.direct(URL(string: "http://192.168.1.20:3000")!)
let relay = InstanceEndpoint.relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
XCTAssertNotEqual(direct.credentialDescription, relay.credentialDescription)
XCTAssertTrue(relay.credentialDescription.contains("agent"))
}
/// Freshness uses the vocabulary that already exists in `packages/contracts`, with the four
/// values spelled the same, so the app and the console describe the same state with one word.
func testFreshnessUsesTheSharedVocabulary() {
XCTAssertEqual(Set(SnapshotFreshness.allCases.map(\.rawValue)),
["fresh", "stale", "expired", "unknown"])
XCTAssertEqual(SnapshotFreshness.classify(age: 2), .fresh)
XCTAssertEqual(SnapshotFreshness.classify(age: 40), .stale)
XCTAssertEqual(SnapshotFreshness.classify(age: 600), .expired)
XCTAssertEqual(SnapshotFreshness.classify(age: nil), .unknown)
XCTAssertEqual(SnapshotFreshness.classify(age: -1), .unknown)
}
static func relayEndpoint(nodeID: String) -> RelayEndpoint {
RelayEndpoint(
relayBaseURL: URL(string: "https://relay.example")!,
nodeID: nodeID,
agentSigningKey: Data(repeating: 0xA1, count: 32),
agentKxKey: Data(repeating: 0xB2, count: 32),
deviceToken: String(repeating: "c3", count: 24),
deviceID: "ap_5b1d0000",
nodeName: "Lab Alpha"
)
}
}
@@ -0,0 +1,105 @@
import XCTest
@testable import MultiSimAdmin
final class InstanceRepositoryTests: XCTestCase {
private var location: URL!
override func setUp() {
super.setUp()
location = FileManager.default.temporaryDirectory
.appendingPathComponent("multi-simadmin-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: location)
super.tearDown()
}
func testCreateListUpdateAndDeleteInstance() async throws {
let repository = FileInstanceRepository(location: location)
let input = InstanceInput(
name: "Lab Alpha",
origin: URL(string: "http://192.168.3.55:3000/")!,
tags: ["primary", "lab"],
password: "secret"
)
let created = try await repository.create(input)
XCTAssertEqual(created.name, "Lab Alpha")
XCTAssertEqual(created.endpoint.displayAddress, "http://192.168.3.55:3000")
XCTAssertEqual(created.tags, ["primary", "lab"])
XCTAssertEqual(created.configRevision, 1)
XCTAssertTrue(created.credentialConfigured)
let patch = InstancePatch(name: "Alpha", tags: ["lab"])
let updated = try await repository.update(created.id, expectedRevision: 1, patch)
XCTAssertEqual(updated.name, "Alpha")
XCTAssertEqual(updated.tags, ["lab"])
XCTAssertEqual(updated.configRevision, 2)
XCTAssertTrue(updated.credentialConfigured)
try await repository.delete(updated.id, expectedRevision: 2)
let afterDelete = try await repository.list(InstanceQuery())
XCTAssertTrue(afterDelete.isEmpty)
}
func testRejectsInvalidOriginDuplicateOriginAndStaleRevision() async throws {
let repository = FileInstanceRepository(location: location)
let origin = URL(string: "http://192.168.1.20:3000")!
let input = InstanceInput(name: "One", origin: origin, tags: [], password: nil)
let first = try await repository.create(input)
do {
let nested = InstanceInput(
name: "Nested",
origin: URL(string: "http://192.168.1.20:3000/admin")!,
tags: [],
password: nil
)
_ = try await repository.create(nested)
XCTFail("Expected nested path rejection")
} catch let error as RepositoryError {
XCTAssertEqual(error, .invalidOrigin)
}
do {
_ = try await repository.create(input)
XCTFail("Expected duplicate-origin failure")
} catch let error as RepositoryError {
XCTAssertEqual(error, .duplicateOrigin)
}
do {
_ = try await repository.update(first.id, expectedRevision: 99, InstancePatch(name: "New"))
XCTFail("Expected stale-revision failure")
} catch let error as RepositoryError {
XCTAssertEqual(error, .revisionConflict)
}
}
func testListFiltersBySearchAndTag() async throws {
let repository = FileInstanceRepository(location: location)
_ = try await repository.create(InstanceInput(
name: "Lab Alpha",
origin: URL(string: "http://192.168.1.10:3000")!,
tags: ["production"],
password: nil
))
_ = try await repository.create(InstanceInput(
name: "Home Beta",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: ["lab"],
password: nil
))
let search = try await repository.list(InstanceQuery(search: "alpha"))
XCTAssertEqual(search.map(\.name), ["Lab Alpha"])
let tagged = try await repository.list(InstanceQuery(tags: ["lab"]))
XCTAssertEqual(tagged.map(\.name), ["Home Beta"])
let production = try await repository.list(InstanceQuery(tags: ["production"]))
XCTAssertEqual(production.map(\.name), ["Lab Alpha"])
let sorted = try await repository.list(InstanceQuery())
XCTAssertEqual(sorted.map(\.name), ["Home Beta", "Lab Alpha"])
}
}
@@ -0,0 +1,50 @@
import XCTest
@testable import MultiSimAdmin
final class InstanceSessionServiceTests: XCTestCase {
func testLoginStoresAndClearsSessionCookie() async throws {
let store = InstanceSessionStore()
let client = MockSessionClient(status: 204)
let service = InstanceSessionService(store: store, client: client)
let instance = Self.instance()
let result = try await service.login(instance, password: "secret")
XCTAssertTrue(result.authenticated)
let cookie = await store.cookie(for: instance.id, origin: instance.endpoint.directOrigin!)
XCTAssertEqual(cookie, "simadmin_session=token")
let rejected = try await service.login(instance, password: "bad")
XCTAssertFalse(rejected.authenticated)
let removed = await store.cookie(for: instance.id, origin: instance.endpoint.directOrigin!)
XCTAssertEqual(removed, nil)
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Session",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
private struct MockSessionClient: SimAdminClientProtocol {
let status: Int
func send(
_ route: UpstreamRoute,
baseURL: URL,
cookie: String?
) async throws -> UpstreamResponse {
guard case let .authLogin(submitted) = route else {
return UpstreamResponse(status: 200, data: Data("{}.utf8".utf8), setCookie: nil)
}
let authorized = submitted == "secret"
let setCookie = authorized ? "simadmin_session=token; Path=/" : nil
return UpstreamResponse(status: authorized ? status : 200, data: Data("{}".utf8), setCookie: setCookie)
}
}
}
@@ -0,0 +1,206 @@
import XCTest
@testable import MultiSimAdmin
final class LogCenterTests: XCTestCase {
func testAggregatesDeliveryLogsConnectionsAndDiagnostics() async throws {
let store = MemoryLogStore()
let service = LogCenterService(
client: LogCenterTestClient(),
sessions: InstanceSessionStore(),
store: store
)
let instance = Self.instance()
let snapshot = try await service.refresh([instance])
XCTAssertEqual(snapshot.runtime.count, 0)
XCTAssertEqual(snapshot.counts[.delivery], 0)
XCTAssertEqual(store.runtimeStorage.count, 0)
XCTAssertEqual(snapshot.connections.count, 1)
XCTAssertEqual(snapshot.connections.first?.outcome, .success)
XCTAssertEqual(snapshot.connections.first?.state, "online")
XCTAssertEqual(store.connectionStorage.count, 1)
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.id, instance.id)
XCTAssertEqual(diagnostic.state, .online)
XCTAssertEqual(diagnostic.featureStatus.map(\.state), [
.ready,
.ready,
.ready
])
XCTAssertEqual(diagnostic.connection?.total, 1)
XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100)
}
func testRecordsUnauthorizedDeliveryLogsWithoutLosingConnectionHistory() async throws {
let store = MemoryLogStore()
let service = LogCenterService(
client: LogCenterTestClient(
),
sessions: InstanceSessionStore(),
store: store
)
let instance = Self.instance()
let snapshot = try await service.refresh([instance])
XCTAssertEqual(snapshot.runtime.count, 0)
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .online)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 3)
XCTAssertEqual(store.runtimeStorage.count, 0)
XCTAssertEqual(store.connectionStorage.count, 1)
}
func testOfflineProbeBecomesFailedConnectionAndErrorLog() async throws {
let store = MemoryLogStore()
let service = LogCenterService(
client: LogCenterTestClient(connectionUnavailable: true),
sessions: InstanceSessionStore(),
store: store
)
let snapshot = try await service.refresh([Self.instance()])
XCTAssertEqual(snapshot.connections.first?.outcome, .failed)
XCTAssertEqual(snapshot.connections.first?.errorCode, "TIMEOUT")
XCTAssertTrue(snapshot.runtime.contains { $0.level == .error && $0.code == "TIMEOUT" })
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .offline)
XCTAssertEqual(diagnostic.healthStatus, "超时")
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 2)
}
@MainActor
func testViewModelFiltersRuntimeLogsAndPrunesHistory() async throws {
let repository = MemoryInstanceRepository()
let logStore = MemoryLogStore()
let service = LogCenterService(
client: LogCenterTestClient(),
sessions: InstanceSessionStore(),
store: logStore
)
let model = LogsViewModel(
repository: repository,
service: service,
store: logStore
)
let instance = try await repository.create(InstanceInput(
name: "Log Node",
origin: URL(string: "http://192.168.1.20:3000")!,
password: nil
))
await model.refresh()
XCTAssertNotNil(model.snapshot)
XCTAssertEqual(model.filteredRuntime.count, 0)
XCTAssertEqual(model.filteredDiagnostics.count, 1)
XCTAssertEqual(model.offlineCount, 0)
model.selectedInstanceID = instance.id
model.searchText = "offline"
model.selectedLevel = .error
XCTAssertEqual(model.filteredRuntime.count, 0)
XCTAssertNil(model.filteredRuntime.first)
let removed = await model.pruneConnections(olderThanDays: 7)
XCTAssertEqual(removed, 0)
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Log Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private final class MemoryLogStore: LogStore, @unchecked Sendable {
private let lock = NSLock()
private var runtimeItems: [RuntimeLogEntry] = []
private var connectionItems: [ConnectionLogEntry] = []
var runtimeStorage: [RuntimeLogEntry] {
lock.withLock { runtimeItems }
}
var connectionStorage: [ConnectionLogEntry] {
lock.withLock { connectionItems }
}
func runtimeLogs() async throws -> [RuntimeLogEntry] {
runtimeStorage
}
func connectionLogs() async throws -> [ConnectionLogEntry] {
connectionStorage
}
func append(
runtime: [RuntimeLogEntry],
connections: [ConnectionLogEntry]
) async throws {
lock.withLock {
var mergedRuntime = runtime
for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) {
mergedRuntime.append(existing)
}
runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt }
var mergedConnections = connections
for existing in connectionItems
where !connections.contains(where: { $0.id == existing.id }) {
mergedConnections.append(existing)
}
connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt }
}
}
func pruneConnections(before _: Date) async throws -> Int {
0
}
func clearRuntimeLogs() async throws -> Int {
0
}
}
private struct LogCenterTestClient: SimAdminClientProtocol {
var failingRoutes: Set<UpstreamRoute> = []
var connectionUnavailable = false
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
if connectionUnavailable {
throw UpstreamError.timeout
}
if failingRoutes.contains(route) {
throw UpstreamError.unauthorized
}
switch route {
case .health:
return Self.response(#"{"status":"UP","version":"1.2.3","platform":"linux"}"#)
case .device:
return Self.response(
#"{"status":"ok","data":{"model":"UFI-003","battery":82,"temperature":42}}"#
)
case .connectivity:
return Self.response(#"{"status":"ok","data":{"connected":true}}"#)
default:
return UpstreamResponse(status: 404, data: Data("{}".utf8))
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -0,0 +1,79 @@
import XCTest
@testable import MultiSimAdmin
final class MessageServiceTests: XCTestCase {
func testParsesUpstreamEnvelopeAndNormalizesMessages() async throws {
let store = InstanceSessionStore()
let client = MessageTestClient()
let service = MessageService(client: client, sessions: store)
let instance = Self.instance()
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
let page = try await service.messages([instance], limit: 2)
XCTAssertEqual(client.routes.first, .smsList(limit: 2, offset: 0, direction: nil))
XCTAssertEqual(page.messages.count, 1)
let message = try XCTUnwrap(page.messages.first)
XCTAssertEqual(message.upstreamID, "42")
XCTAssertEqual(message.id, "\(instance.id.uuidString)#42")
XCTAssertEqual(message.instanceID, instance.id)
XCTAssertEqual(message.instanceName, "Message Node")
XCTAssertEqual(message.phoneNumber, "13800138000")
XCTAssertEqual(message.content, "device ready")
XCTAssertEqual(message.direction, .outgoing)
XCTAssertEqual(message.status, "sent")
}
func testSendUsesStoredSessionAndRejectsInvalidInput() async throws {
let store = InstanceSessionStore()
let client = MessageTestClient()
let service = MessageService(client: client, sessions: store)
let instance = Self.instance()
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
try await service.send(instance, phoneNumber: "13800138000", content: "hello")
XCTAssertEqual(client.routes.last, .smsSend(phoneNumber: "13800138000", content: "hello"))
do {
try await service.send(instance, phoneNumber: "", content: "hello")
XCTFail("Empty phone number should fail.")
} catch {
XCTAssertEqual(error as? MessageServiceError, .invalidInput)
}
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Message Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private final class MessageTestClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
guard cookie == "simadmin_session=token" else {
throw UpstreamError.unauthorized
}
switch route {
case .smsList:
let json = """
{"status":"ok","data":{"total":1,"messages":[{"id":42,"phone_number":"13800138000",
"content":"device ready","direction":"outgoing","status":"sent","timestamp":"2026-09-08T10:00:00Z"}]}}
"""
return UpstreamResponse(status: 200, data: Data(json.utf8))
case .smsSend:
return UpstreamResponse(status: 200, data: Data(#"{"status":"ok"}"#.utf8))
default:
return UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
}
@@ -0,0 +1,343 @@
import CryptoKit
import XCTest
@testable import MultiSimAdmin
/// The pairing exchange, checked against a node-shaped peer.
///
/// Pairing is the one moment the phone is inside the network and the only time the node's password
/// is involved at all, so the interesting properties are that the phone sends its real public keys,
/// that it records the id the node assigns, and that each failure mode is distinguishable — a wrong
/// password and a closed window call for different actions.
final class RelayPairingTests: XCTestCase {
private final class PairingStub: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var handler: (@Sendable (URLRequest, Data) throws -> (Int, Data))?
nonisolated(unsafe) static var lastBody: Data?
nonisolated(unsafe) static var lastURL: URL?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let body = request.httpBody ?? Self.drain(request.httpBodyStream) ?? Data()
Self.lastBody = body
Self.lastURL = request.url
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
do {
let (status, data) = try handler(request, body)
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
private static func drain(_ stream: InputStream?) -> Data? {
guard let stream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: buffer.count)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}
private func makePairing() -> RelayPairing {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairingStub.self]
return RelayPairing(session: URLSession(configuration: configuration))
}
private func makeIdentity() -> DeviceIdentity {
DeviceIdentity(
deviceID: "",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
}
private func successBody(agentSigning: Data, agentKx: Data) -> Data {
Data("""
{
"nodeID": "nd_7f3a9c2e",
"appID": "ap_5b1d0000",
"agentSignPub": "\(agentSigning.base64EncodedString())",
"agentKxPub": "\(agentKx.base64EncodedString())",
"relayURL": "https://relay.example",
"deviceToken": "0123456789abcdef0123456789abcdef"
}
""".utf8)
}
override func tearDown() {
PairingStub.handler = nil
PairingStub.lastBody = nil
PairingStub.lastURL = nil
super.tearDown()
}
func testPairingSendsTheRealPublicKeysAndReturnsTheAssignedIdentity() async throws {
let identity = makeIdentity()
let agentSigning = Curve25519.Signing.PrivateKey()
let agentKx = Curve25519.KeyAgreement.PrivateKey()
PairingStub.handler = { _, _ in
(200, self.successBody(
agentSigning: agentSigning.publicKey.rawRepresentation,
agentKx: agentKx.publicKey.rawRepresentation
))
}
let result = try await makePairing().pair(
host: "192.168.1.10",
password: "hunter2",
deviceName: "chick",
identity: identity
)
// The keys the phone sent must be the ones it will sign and decrypt with; the node stores
// them as its trust anchor, and a mismatch here is an unverifiable request later.
let sent = try XCTUnwrap(
try JSONSerialization.jsonObject(with: try XCTUnwrap(PairingStub.lastBody)) as? [String: Any]
)
XCTAssertEqual(sent["appSignPub"] as? String, identity.signingPublicKey.base64EncodedString())
XCTAssertEqual(sent["appKxPub"] as? String, identity.agreementPublicKey.base64EncodedString())
XCTAssertEqual(sent["password"] as? String, "hunter2")
XCTAssertEqual(sent["deviceName"] as? String, "chick")
XCTAssertEqual(PairingStub.lastURL?.absoluteString, "http://192.168.1.10:3001/provision")
// The id is the node's choice, and the returned keys become the phone's trust anchor.
XCTAssertEqual(result.deviceID, "ap_5b1d0000")
XCTAssertEqual(result.endpoint.nodeID, "nd_7f3a9c2e")
XCTAssertEqual(result.endpoint.deviceID, "ap_5b1d0000")
XCTAssertEqual(result.endpoint.agentSigningKey, agentSigning.publicKey.rawRepresentation)
XCTAssertEqual(result.endpoint.agentKxKey, agentKx.publicKey.rawRepresentation)
XCTAssertEqual(result.endpoint.deviceToken, "0123456789abcdef0123456789abcdef")
XCTAssertEqual(result.endpoint.relayBaseURL.absoluteString, "https://relay.example")
}
/// Each failure mode must be its own error: "wrong password" is a retype, "window closed" is a
/// walk to the node, and neither is fixed by the other's action.
func testFailuresAreDistinguishable() async {
let identity = makeIdentity()
let cases: [(Int, Data, RelayPairing.Failure)] = [
(401, Data(#"{"error":"no"}"#.utf8), .rejected),
(410, Data(#"{"error":"closed"}"#.utf8), .windowClosed),
(500, Data(#"{"error":"boom"}"#.utf8), .malformedResponse),
]
for (status, body, expected) in cases {
PairingStub.handler = { _, _ in (status, body) }
do {
_ = try await makePairing().pair(
host: "192.168.1.10", password: "x", deviceName: "d", identity: identity
)
XCTFail("status \(status) was accepted")
} catch let failure as RelayPairing.Failure {
XCTAssertEqual(failure, expected, "status \(status)")
} catch {
XCTFail("status \(status) produced \(error)")
}
}
}
/// A malformed or truncated response must be refused rather than half-accepted: an endpoint
/// missing its keys would fail later as an unreadable response, which the user reads as a broken
/// node rather than as a bad pairing.
func testAMalformedResponseIsRefused() async {
let identity = makeIdentity()
let good = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation
let goodKx = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let bodies: [Data] = [
Data("not json".utf8),
Data(#"{"nodeID":"nd_7f3a9c2e"}"#.utf8),
// A key that is not 32 bytes decodes fine as base64 and is wrong for the format.
Data("""
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(Data(repeating: 1, count: 31).base64EncodedString())",
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"https://relay.example","deviceToken":"t"}
""".utf8),
// A plain-HTTP relay would make the end-to-end encryption decorative.
Data("""
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(good.base64EncodedString())",
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"http://relay.example","deviceToken":"t"}
""".utf8),
// No token: the phone could never authenticate to the relay.
Data("""
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(good.base64EncodedString())",
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"https://relay.example","deviceToken":""}
""".utf8),
]
for body in bodies {
PairingStub.handler = { _, _ in (200, body) }
do {
_ = try await makePairing().pair(
host: "192.168.1.10", password: "x", deviceName: "d", identity: identity
)
XCTFail("accepted \(String(decoding: body, as: UTF8.self))")
} catch let failure as RelayPairing.Failure {
XCTAssertEqual(failure, .malformedResponse)
} catch {
XCTFail("produced \(error)")
}
}
}
func testAnUnreachableNodeIsReportedAsSuch() async {
PairingStub.handler = { _, _ in throw URLError(.cannotConnectToHost) }
do {
_ = try await makePairing().pair(
host: "192.168.1.10", password: "x", deviceName: "d", identity: makeIdentity()
)
XCTFail("an unreachable node was accepted")
} catch let failure as RelayPairing.Failure {
XCTAssertEqual(failure, .unreachable)
} catch {
XCTFail("produced \(error)")
}
}
}
/// The identity file is the one thing a pairing cannot be redone without.
final class DeviceIdentityStoreTests: XCTestCase {
private var location: URL!
override func setUp() {
super.setUp()
location = FileManager.default.temporaryDirectory
.appendingPathComponent("msa-identity-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: location)
super.tearDown()
}
func testTheIdentityIsStableAcrossLoads() throws {
let store = FileDeviceIdentityStore(location: location)
let first = try store.loadOrCreate()
let second = try store.loadOrCreate()
XCTAssertEqual(first.signingPublicKey, second.signingPublicKey)
XCTAssertEqual(first.agreementPublicKey, second.agreementPublicKey)
XCTAssertTrue(first.deviceID.isEmpty, "the phone cannot know its id before the node assigns one")
// A fresh store over the same file must find the same keys, or a restart would silently
// invalidate every pairing.
let reopened = FileDeviceIdentityStore(location: location)
XCTAssertEqual(try reopened.loadOrCreate().signingPublicKey, first.signingPublicKey)
}
/// The node verified the public keys it was given, so recording its id must not regenerate them.
func testAssigningTheDeviceIDKeepsTheKeys() throws {
let store = FileDeviceIdentityStore(location: location)
let original = try store.loadOrCreate()
let assigned = try store.assignDeviceID("ap_5b1d0000")
XCTAssertEqual(assigned.deviceID, "ap_5b1d0000")
XCTAssertEqual(assigned.signingPublicKey, original.signingPublicKey)
XCTAssertEqual(assigned.agreementPublicKey, original.agreementPublicKey)
let reloaded = try FileDeviceIdentityStore(location: location).loadOrCreate()
XCTAssertEqual(reloaded.deviceID, "ap_5b1d0000")
XCTAssertEqual(reloaded.signingPublicKey, original.signingPublicKey)
}
func testTheFileIsNotWorldReadable() throws {
_ = try FileDeviceIdentityStore(location: location).loadOrCreate()
let mode = try FileManager.default
.attributesOfItem(atPath: location.path)[.posixPermissions] as? NSNumber
XCTAssertEqual(mode?.int16Value, 0o600)
}
func testResetForgetsTheIdentity() throws {
let store = FileDeviceIdentityStore(location: location)
let first = try store.loadOrCreate()
try store.reset()
let second = try store.loadOrCreate()
XCTAssertNotEqual(first.signingPublicKey, second.signingPublicKey)
}
}
/// The router is the only place that decides which path a request takes.
final class NodeTransportRouterTests: XCTestCase {
private struct Recorder: NodeTransport {
let label: String
let recorder: @Sendable (String) -> Void
func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
recorder(label)
return UpstreamResponse(status: 200, data: Data())
}
}
func testTheRouterPicksThePathFromTheEndpoint() async throws {
let seen = LockedStrings()
let router = NodeTransportRouter(
direct: Recorder(label: "direct") { seen.append($0) },
relay: Recorder(label: "relay") { seen.append($0) }
)
_ = try await router.send(
.health,
instanceID: UUID(),
endpoint: .direct(URL(string: "http://192.168.1.20:3000")!)
)
_ = try await router.send(
.health,
instanceID: UUID(),
endpoint: .relay(InstanceEndpointMigrationTests.relayEndpoint(nodeID: "nd_7f3a9c2e"))
)
XCTAssertEqual(seen.values, ["direct", "relay"])
}
/// Handing a relay instance to the direct path is a wiring bug, not a network condition, so it
/// must fail rather than dialing something arbitrary.
func testTheDirectPathRefusesARelayEndpoint() async {
let transport = DirectTransport(client: NoopClient(), sessions: InstanceSessionStore())
do {
_ = try await transport.send(
.health,
instanceID: UUID(),
endpoint: .relay(InstanceEndpointMigrationTests.relayEndpoint(nodeID: "nd_7f3a9c2e"))
)
XCTFail("the direct path accepted a relay endpoint")
} catch let error as UpstreamError {
XCTAssertEqual(error, .invalidRequest)
} catch {
XCTFail("produced \(error)")
}
}
private struct NoopClient: SimAdminClientProtocol {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
XCTFail("the direct client must not be dialed for a relay endpoint")
return UpstreamResponse(status: 200, data: Data())
}
}
private final class LockedStrings: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String] = []
func append(_ value: String) {
lock.withLock { storage.append(value) }
}
var values: [String] {
lock.withLock { storage }
}
}
}
@@ -0,0 +1,506 @@
import CryptoKit
import XCTest
@testable import MultiSimAdmin
/// The phone's half of the protocol, checked against a peer that behaves like the Go agent.
///
/// The peer here is built from the same primitives the agent uses, in the opposite direction: it
/// verifies the request signature with the phone's public key, decrypts the payload with the agent's
/// private X25519 key, recomputes the confirmation intent, and answers with a signature and a
/// ciphertext the phone has to accept. A test that only stubbed the transport would prove the
/// plumbing; this proves the protocol, which is the part that has three implementations and no
/// shared code.
final class RelayTransportTests: XCTestCase {
// MARK: - Agent-shaped peer
/// Stands in for the node's agent and for the relay at once: the relay is a dumb pipe, so the
/// only interesting behaviour is what the agent does with the envelope.
private struct AgentPeer {
let nodeID: String
let deviceID: String
let signingKey: Curve25519.Signing.PrivateKey
let agreementKey: Curve25519.KeyAgreement.PrivateKey
init(nodeID: String = "nd_7f3a9c2e", deviceID: String = "ap_5b1d0000") {
self.nodeID = nodeID
self.deviceID = deviceID
self.signingKey = Curve25519.Signing.PrivateKey()
self.agreementKey = Curve25519.KeyAgreement.PrivateKey()
}
/// What the phone needs in order to reach this peer.
func endpoint(relayBaseURL: URL = URL(string: "https://relay.example")!) -> RelayEndpoint {
RelayEndpoint(
relayBaseURL: relayBaseURL,
nodeID: nodeID,
agentSigningKey: signingKey.publicKey.rawRepresentation,
agentKxKey: agreementKey.publicKey.rawRepresentation,
deviceToken: "device-token-abcdef",
deviceID: deviceID,
nodeName: "Lab Alpha"
)
}
struct Received {
var inner: RelayWire.InnerRequest
var envelope: RelayWire.Envelope
}
/// Verifies and decrypts one request the way the agent does, in the agent's order.
func receive(_ raw: Data, phonePublicKey: Curve25519.Signing.PublicKey) throws -> Received {
let envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
try RelayWire.verifyRequest(envelope, publicKey: phonePublicKey)
let shared = try RelayWire.sharedSecret(privateKey: agreementKey, peerPublic: envelope.ek)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: nodeID,
requestID: envelope.req,
response: false
)
let plaintext = try RelayWire.open(envelope.p, key: key, iv: iv)
let inner = try JSONDecoder().decode(RelayWire.InnerRequest.self, from: plaintext)
return Received(inner: inner, envelope: envelope)
}
/// Answers the way the agent does: a fresh ephemeral key, encrypted to the phone's request
/// ephemeral, signed with the agent's long-term key, and with `app` omitted from the signed
/// canonical form.
func answer(
to request: RelayWire.Envelope,
status: Int,
outcome: String?,
body: Data?
) throws -> Data {
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let shared = try RelayWire.sharedSecret(
privateKey: ephemeral,
peerPublic: request.ek
)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: nodeID,
requestID: request.req,
response: true
)
let inner = RelayWire.InnerResponse(status: status, outcome: outcome, body: body)
let plaintext = try JSONEncoder().encode(inner)
var envelope = RelayWire.Envelope(
node: nodeID,
req: request.req,
app: nil,
ts: request.ts + 1,
ek: ephemeral.publicKey.rawRepresentation,
p: try RelayWire.seal(plaintext, key: key, iv: iv)
)
envelope.s = try signingKey.signature(for: RelayWire.canonical(envelope, response: true))
return try JSONEncoder().encode(envelope)
}
}
/// A URLProtocol stub, so the transport under test runs its real code path: real envelope
/// construction, real signing, real HTTP handling.
private final class RelayStub: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var handler: (@Sendable (URLRequest, Data) throws -> (Int, Data))?
nonisolated(unsafe) static var lastRequest: URLRequest?
nonisolated(unsafe) static var lastBody: Data?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
// `URLProtocol` hands the body as a stream, so it has to be drained here to get bytes.
let body = request.httpBody ?? Self.drain(request.httpBodyStream)
Self.lastRequest = request
Self.lastBody = body
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
do {
let (status, data) = try handler(request, body ?? Data())
let response = HTTPURLResponse(
url: request.url!, statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["content-type": "application/problem+json"]
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
private static func drain(_ stream: InputStream?) -> Data? {
guard let stream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: buffer.count)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}
private func makeTransport(
identity: DeviceIdentity,
now: @escaping @Sendable () -> Date = { Date() }
) -> RelayTransport {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [RelayStub.self]
return RelayTransport(
identity: identity,
session: URLSession(configuration: configuration),
now: now
)
}
private func makeIdentity() -> DeviceIdentity {
DeviceIdentity(
deviceID: "ap_5b1d0000",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
}
override func tearDown() {
RelayStub.handler = nil
RelayStub.lastRequest = nil
RelayStub.lastBody = nil
super.tearDown()
}
// MARK: - Round trip
func testAReadRoundTripsThroughAnAgentShapedPeer() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
var received: AgentPeer.Received?
RelayStub.handler = { request, body in
received = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
let answer = try peer.answer(
to: received!.envelope,
status: 200,
outcome: AgentOutcome.ok.rawValue,
body: Data(#"{"status":"up"}"#.utf8)
)
return (200, answer)
}
let transport = makeTransport(identity: identity)
let response = try await transport.send(
.health,
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
XCTAssertEqual(response.status, 200)
XCTAssertEqual(String(decoding: response.data, as: UTF8.self), #"{"status":"up"}"#)
let seen = try XCTUnwrap(received)
XCTAssertEqual(seen.inner.m, "GET")
XCTAssertEqual(seen.inner.p, "/api/health")
XCTAssertNil(seen.inner.intent, "a read must not carry a confirmation intent")
XCTAssertEqual(seen.envelope.app, "ap_5b1d0000")
}
/// The path carries its query string, and the agent forwards it verbatim: the phone must not
/// assemble one from parts, because a part the agent reassembled is a part a relay could steer.
func testThePathCarriesItsQueryString() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
var path: String?
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
path = seen.inner.p
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
}
let transport = makeTransport(identity: identity)
_ = try await transport.send(
.smsList(limit: 25, offset: 50, direction: "incoming"),
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
XCTAssertEqual(path, "/api/sms/list?limit=25&offset=50&direction=incoming")
}
/// A dangerous write carries an intent that the agent recomputes from the bytes it is about to
/// forward. If the phone's digest were computed over different bytes, the agent would refuse it
/// — which is the property that makes a relay unable to edit an SMS in flight.
func testADangerousWriteCarriesAnIntentTheAgentRecomputes() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
var inner: RelayWire.InnerRequest?
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
inner = seen.inner
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
}
let transport = makeTransport(identity: identity)
_ = try await transport.send(
.smsSend(phoneNumber: "+8613800000000", content: "hello"),
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
let seen = try XCTUnwrap(inner)
let intent = try XCTUnwrap(seen.intent, "a dangerous write must carry an intent")
let recomputed = try RelayWire.intentDigest(
method: "POST",
path: "/api/sms/send",
body: seen.body
)
XCTAssertEqual(intent, recomputed, "the agent recomputes this digest and refuses a mismatch")
// And the digest must not survive an edit to the body, which is the attack it exists for.
let edited = try RelayWire.intentDigest(
method: "POST",
path: "/api/sms/send",
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
)
XCTAssertNotEqual(intent, edited)
}
/// The relay must not be able to answer without the agent's key. This is the check that makes
/// "the relay cannot forge a response" a fact rather than an assumption.
func testAnUnsignedAnswerIsRejected() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
let raw = try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8))
var envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
// A forged answer: correctly shaped, wrong signer.
let forger = Curve25519.Signing.PrivateKey()
envelope.s = try forger.signature(for: RelayWire.canonical(envelope, response: true))
return (200, try JSONEncoder().encode(envelope))
}
let transport = makeTransport(identity: identity)
await assertThrows(.relayResponseUnverifiable) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
/// A relay that swaps in a different ciphertext cannot produce a valid signature over it either,
/// since the signature covers the ciphertext.
func testATamperedCiphertextIsRejected() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
let raw = try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8))
var envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
var ciphertext = envelope.p
ciphertext[ciphertext.count - 1] ^= 0x01
envelope.p = ciphertext
return (200, try JSONEncoder().encode(envelope))
}
let transport = makeTransport(identity: identity)
await assertThrows(.relayResponseUnverifiable) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
/// An answer to a different request id must be discarded: the signature covers `req`, so this
/// catches both a confused relay and a replay of an earlier answer.
func testAnAnswerForADifferentRequestIsRejected() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
var misdirected = seen.envelope
misdirected.req = UUID().uuidString.lowercased()
// Re-signing with the real key would make it valid; the point is that the phone checks
// the id before it checks anything else, so an unsigned mismatch is enough.
misdirected.s = Data(repeating: 0, count: 64)
let raw = try JSONEncoder().encode(misdirected)
return (200, raw)
}
let transport = makeTransport(identity: identity)
await assertThrows(.relayResponseMismatch) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
// MARK: - Error mapping
/// Every relay error code must arrive as its own typed error. Collapsing them would put the
/// user back to one message for four different situations.
func testRelayProblemCodesMapToDistinctErrors() {
let cases: [(RelayErrorCode, UpstreamError)] = [
(.unauthorized, .relayRejectedDevice),
(.nodeUnknown, .relayNodeUnknown),
(.agentOffline, .agentOffline),
(.mailboxFull, .nodeBusy),
(.agentTimeout, .agentTimeout),
(.badEnvelope, .relayBadEnvelope),
(.payloadTooLarge, .payloadTooLarge),
(.rateLimited, .relayRateLimited),
(.internalError, .relayUnreachable),
]
for (code, expected) in cases {
let body = Data(#"{"code":"\#(code.rawValue)","status":500}"#.utf8)
XCTAssertEqual(RelayTransport.problem(from: body, status: 500), expected, code.rawValue)
}
// An unrecognised code on a 5xx is a relay fault; on a 4xx it is a protocol mismatch.
XCTAssertEqual(
RelayTransport.problem(from: Data(#"{"code":"something_new"}"#.utf8), status: 503),
.relayUnreachable
)
XCTAssertEqual(
RelayTransport.problem(from: Data(#"{"code":"something_new"}"#.utf8), status: 400),
.invalidResponse
)
}
func testAnAgentRefusalArrivesAsItsOwnError() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
let cases: [(String, UpstreamError)] = [
(AgentOutcome.nodeAuthFailed.rawValue, .nodeAuthFailed),
(AgentOutcome.policyDenied.rawValue, .relayPolicyDenied),
(AgentOutcome.confirmationMismatch.rawValue, .confirmationRejected),
(AgentOutcome.expired.rawValue, .envelopeExpired),
(AgentOutcome.replay.rawValue, .envelopeExpired),
(AgentOutcome.rateLimited.rawValue, .nodeRateLimited),
(AgentOutcome.badSignature.rawValue, .envelopeRejected),
]
for (outcome, expected) in cases {
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
return (200, try peer.answer(to: seen.envelope, status: 403, outcome: outcome, body: nil))
}
let transport = makeTransport(identity: identity)
await assertThrows(expected, "outcome \(outcome)") {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
}
/// An upstream error from the node is the one branch where the node's own status reaches the
/// caller: the agent did its job, SimAdmin answered with an error, and the status is meaningful.
func testAnUpstreamErrorKeepsTheNodeStatus() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
return (200, try peer.answer(
to: seen.envelope,
status: 502,
outcome: AgentOutcome.upstreamError.rawValue,
body: Data(#"{"error":"upstream"}"#.utf8)
))
}
let transport = makeTransport(identity: identity)
let response = try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
XCTAssertEqual(response.status, 502)
XCTAssertEqual(String(decoding: response.data, as: UTF8.self), #"{"error":"upstream"}"#)
}
/// The relay is a bearer-authenticated hop, and the token must travel as a bearer header rather
/// than in the envelope: it is the relay's abuse control, not part of the signed material.
func testTheRequestCarriesTheDeviceBearerAndNeverAPassword() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
}
let transport = makeTransport(identity: identity)
_ = try await transport.send(
.smsSend(phoneNumber: "+8613800000000", content: "hello"),
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
let request = try XCTUnwrap(RelayStub.lastRequest)
XCTAssertEqual(request.url?.path, "/v1/request")
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(
request.value(forHTTPHeaderField: "authorization"),
"Bearer device-token-abcdef"
)
// The node password never appears anywhere in what leaves the phone.
let body = String(decoding: try XCTUnwrap(RelayStub.lastBody), as: UTF8.self)
XCTAssertFalse(body.lowercased().contains("password"))
}
/// Plain HTTP to a relay would make the end-to-end encryption decorative, so it is refused
/// before any traffic is sent.
func testAPlainHTTPRelayIsRefused() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, _ in
XCTFail("a plain-HTTP relay must be refused before any request is made")
return (500, Data())
}
let transport = makeTransport(identity: identity)
var endpoint = peer.endpoint(relayBaseURL: URL(string: "http://relay.example")!)
endpoint.relayBaseURL = URL(string: "http://relay.example")!
await assertThrows(.invalidRequest) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(endpoint))
}
}
// MARK: - Freshness and status vocabulary
func testProbeStatusMappingKeepsTheRelayCasesApart() {
XCTAssertEqual(ConnectionProbe.status(for: .agentOffline, error: UpstreamError.agentOffline), .offline)
XCTAssertEqual(ConnectionProbe.status(for: .relayUnreachable, error: UpstreamError.relayUnreachable), .offline)
XCTAssertEqual(ConnectionProbe.status(for: .relayRejectedDevice, error: UpstreamError.relayRejectedDevice), .offline)
XCTAssertEqual(ConnectionProbe.status(for: .nodeAuthFailed, error: UpstreamError.nodeAuthFailed), .degraded)
XCTAssertEqual(ConnectionProbe.status(for: .confirmationRejected, error: UpstreamError.confirmationRejected), .degraded)
XCTAssertEqual(ConnectionProbe.status(for: .unauthorized, error: UpstreamError.unauthorized), .authRequired)
// Every relay-mode case must have its own message. A generic fallback is the failure this
// whole vocabulary exists to prevent, so the check is that the text is specific rather than
// merely present.
let generic = "请求失败:"
var seen = Set<String>()
for error in Self.allRelayErrors {
let text = ErrorMessage.describe(error, nodeName: "Lab Alpha")
XCTAssertFalse(text.isEmpty)
XCTAssertFalse(text.hasPrefix(generic), "\(error) fell back to the generic message")
XCTAssertTrue(seen.insert(text).inserted, "\(error) shares a message with another case")
}
XCTAssertEqual(seen.count, Self.allRelayErrors.count)
}
static let allRelayErrors: [UpstreamError] = [
.relayUnreachable, .relayTimeout, .relayRejectedDevice, .relayNodeUnknown, .agentOffline,
.nodeBusy, .agentTimeout, .relayBadEnvelope, .relayRateLimited, .relayPolicyDenied,
.confirmationRejected, .envelopeExpired, .nodeRateLimited, .nodeAuthFailed, .payloadTooLarge,
.relayResponseUnverifiable, .relayResponseMismatch, .envelopeRejected,
]
// MARK: - Helpers
private func assertThrows(
_ expected: UpstreamError,
_ message: String = "",
_ body: () async throws -> UpstreamResponse,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
_ = try await body()
XCTFail("expected \(expected) but the call succeeded. \(message)", file: file, line: line)
} catch let error as UpstreamError {
XCTAssertEqual(error, expected, message, file: file, line: line)
} catch {
XCTFail("expected \(expected), got \(error). \(message)", file: file, line: line)
}
}
}
@@ -0,0 +1,342 @@
import CryptoKit
import XCTest
@testable import MultiSimAdmin
/// Recomputes `contracts/test-vectors.json` in Swift.
///
/// The Go agent, the Node reference implementation and this app share no code, so agreement here is
/// evidence rather than a tautology: three encoders producing the same bytes for the same inputs is
/// what makes "the phone signs, the agent verifies" true across a language boundary. A failure in
/// this file means the encoding drifted, not that this side is broken.
final class RelayWireTests: XCTestCase {
/// The vector file lives in the relay repository. Both paths are tried so the test works from
/// the app package and from a checkout where the two repositories sit side by side; when
/// neither exists the test is skipped rather than failed, because a missing sibling repository
/// is not an encoding bug.
private static let candidatePaths = [
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
FileManager.default.currentDirectoryPath + "/../../../simadmin-relay/contracts/test-vectors.json",
].compactMap { $0 }
private func loadVectors() throws -> [String: Any] {
for path in Self.candidatePaths where FileManager.default.fileExists(atPath: path) {
let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XCTSkip("vectors at \(path) are not a JSON object")
}
return object
}
throw XCTSkip("simadmin-relay/contracts/test-vectors.json not found; set MSA_RELAY_VECTORS")
}
private func hex(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
private func unhex(_ text: String) -> Data {
var out = Data()
var index = text.startIndex
while index < text.endIndex {
guard let next = text.index(index, offsetBy: 2, limitedBy: text.endIndex),
let byte = UInt8(text[index..<next], radix: 16)
else {
XCTFail("not hex: \(text)")
return Data()
}
out.append(byte)
index = next
}
return out
}
/// The envelope in the vector file is JSON, so its byte fields are base64 there while the
/// key-material sections are hex. Both decoders are needed and mixing them up produces an empty
/// Data that fails as a length error three assertions later.
private func unbase64(_ text: String) -> Data {
guard let data = Data(base64Encoded: text) else {
XCTFail("not base64: \(text)")
return Data()
}
return data
}
/// RFC 8032 seed halves, which are the 32-byte raw representations CryptoKit wants. The vector
/// file stores the full 64-byte expanded form that Go's ed25519 package uses.
private func seed(_ expanded: String) -> Data {
unhex(String(expanded.prefix(64)))
}
// MARK: - Envelope encoding
func testCanonicalBytesAndSignaturesMatchTheGoAgent() throws {
let vectors = try loadVectors()
guard let canonical = vectors["canonical"] as? [String: Any],
let input = canonical["input"] as? [String: Any],
let bytes = canonical["bytes"] as? [String: String],
let signatures = canonical["signature"] as? [String: String],
let publicKeyHex = canonical["publicKey"] as? String,
let responsePublicKeyHex = canonical["responsePublicKey"] as? String
else {
return XCTFail("the canonical section is missing or malformed")
}
let envelope = RelayWire.Envelope(
v: input["v"] as? UInt64 ?? 0,
node: input["node"] as? String ?? "",
req: input["req"] as? String ?? "",
app: input["app"] as? String,
ts: input["ts"] as? UInt64 ?? 0,
ek: unbase64(input["ek"] as? String ?? ""),
alg: input["alg"] as? String ?? "",
p: unbase64(input["p"] as? String ?? "")
)
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: false)), bytes["request"])
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: true)), bytes["response"])
let appSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
let agentSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(agentSignKeyHex))
// The public halves published in the vector file must be the ones these private keys derive,
// or the vector proves nothing about key ownership.
XCTAssertEqual(hex(appSigningKey.publicKey.rawRepresentation), publicKeyHex)
XCTAssertEqual(hex(agentSigningKey.publicKey.rawRepresentation), responsePublicKeyHex)
// CryptoKit's Ed25519 signing is randomised, unlike Go's and Node's deterministic RFC 8032
// implementation: signing the same bytes twice yields two different, equally valid
// signatures. So the cross-language check here cannot be "the bytes are equal" — it is the
// stronger property that actually matters, which is that each side verifies what the other
// produced. Both directions are asserted, because a phone whose signatures the agent
// rejected would be a total failure that byte-equality would never have caught either.
try RelayWire.verifyRequest(
envelopeWithSignature(envelope, unhex(signatures["request"] ?? ""), app: true),
publicKey: appSigningKey.publicKey
)
try RelayWire.verifyResponse(
envelopeWithSignature(envelope, unhex(signatures["response"] ?? ""), app: false),
publicKey: agentSigningKey.publicKey
)
var signedRequest = envelope
try RelayWire.signRequest(&signedRequest, privateKey: appSigningKey)
try RelayWire.verifyRequest(signedRequest, publicKey: appSigningKey.publicKey)
// And the Go side must accept what this side signed: the vectors cannot prove that from
// here, so it is asserted by the agent's own test suite against a signature captured from
// a device. What can be checked locally is that the signing input is the vector's bytes.
XCTAssertEqual(
hex(try RelayWire.canonical(signedRequest, response: false)),
bytes["request"],
"the phone must sign exactly the bytes the agent verifies"
)
// Domain separation: a request signature must not verify as a response signature.
XCTAssertThrowsError(try RelayWire.verifyResponse(signedRequest, publicKey: appSigningKey.publicKey))
}
/// Rebuilds an envelope carrying a signature produced elsewhere, since the response form has no
/// `app` field and the request form does.
private func envelopeWithSignature(
_ envelope: RelayWire.Envelope,
_ signature: Data,
app: Bool
) -> RelayWire.Envelope {
var copy = envelope
copy.app = app ? envelope.app : nil
copy.s = signature
return copy
}
/// RFC 8032 test vector private keys, repeated here so the test does not depend on the vector
/// file to know whose key is whose.
private let privateKeyHex =
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
private let agentSignKeyHex =
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"
+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
func testAOneByteChangeBreaksTheSignature() throws {
let envelope = RelayWire.Envelope(
node: "nd_7f3a9c2e",
req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
app: "ap_5b1d",
ts: 1_727_289_600,
ek: Data(repeating: 7, count: 32),
p: Data([0, 1, 2, 0xFE, 0xFF])
)
let key = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
var signed = envelope
try RelayWire.signRequest(&signed, privateKey: key)
try RelayWire.verifyRequest(signed, publicKey: key.publicKey)
// Retargeting the node is what a compromised relay would try first.
var retargeted = signed
retargeted.node = "nd_other001"
XCTAssertThrowsError(try RelayWire.verifyRequest(retargeted, publicKey: key.publicKey)) { error in
XCTAssertEqual(error as? RelayWire.Failure, .signatureDoesNotVerify)
}
// Swapping the ciphertext is the second thing it would try.
var edited = signed
edited.p = Data([9, 9, 9])
XCTAssertThrowsError(try RelayWire.verifyRequest(edited, publicKey: key.publicKey))
// The ephemeral public key is covered, so a substituted one cannot be hidden.
var swapped = signed
swapped.ek = Data(repeating: 3, count: 32)
XCTAssertThrowsError(try RelayWire.verifyRequest(swapped, publicKey: key.publicKey))
}
// MARK: - Key derivation and AEAD
func testKeyDerivationAndAEADMatchTheGoAgent() throws {
let vectors = try loadVectors()
guard let x25519 = vectors["x25519"] as? [String: String],
let aead = vectors["aead"] as? [String: String]
else {
return XCTFail("the x25519 or aead section is missing")
}
let privateKey = try Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: unhex(x25519["appPrivate"] ?? "")
)
let shared = try RelayWire.sharedSecret(
privateKey: privateKey,
peerPublic: unhex(x25519["agentPublic"] ?? "")
)
XCTAssertEqual(hex(shared), x25519["sharedSecret"])
let request = try RelayWire.deriveKey(
shared: shared,
nodeID: "nd_7f3a9c2e",
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
response: false
)
XCTAssertEqual(hex(request.key.withUnsafeBytes { Data($0) }), x25519["requestKey"])
XCTAssertEqual(hex(request.iv), x25519["requestIV"])
let response = try RelayWire.deriveKey(
shared: shared,
nodeID: "nd_7f3a9c2e",
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
response: true
)
XCTAssertEqual(hex(response.key.withUnsafeBytes { Data($0) }), x25519["responseKey"])
XCTAssertEqual(hex(response.iv), x25519["responseIV"])
// Direction separation: the same shared secret must not produce the same key or nonce.
XCTAssertNotEqual(x25519["requestKey"], x25519["responseKey"])
XCTAssertNotEqual(x25519["requestIV"], x25519["responseIV"])
let sealed = try RelayWire.seal(
unhex(aead["plaintext"] ?? ""),
key: request.key,
iv: request.iv
)
XCTAssertEqual(hex(sealed), aead["ciphertext"])
let opened = try RelayWire.open(sealed, key: request.key, iv: request.iv)
XCTAssertEqual(hex(opened), aead["plaintext"])
}
func testHKDFMatchesRFC5869() throws {
let vectors = try loadVectors()
guard let hkdf = vectors["hkdf"] as? [String: Any] else {
return XCTFail("the hkdf section is missing")
}
let output = try RelayWire.hkdf(
secret: unhex(hkdf["secret"] as? String ?? ""),
salt: unhex(hkdf["salt"] as? String ?? ""),
info: unhex(hkdf["info"] as? String ?? ""),
length: hkdf["length"] as? Int ?? 0
)
XCTAssertEqual(hex(output), hkdf["output"] as? String)
}
/// A tampered ciphertext must fail to authenticate rather than decrypt to something.
func testATamperedCiphertextDoesNotOpen() throws {
let key = SymmetricKey(data: Data(repeating: 1, count: 32))
let iv = Data(repeating: 2, count: 12)
var sealed = try RelayWire.seal(Data("hello".utf8), key: key, iv: iv)
sealed[sealed.count - 1] ^= 0x01
XCTAssertThrowsError(try RelayWire.open(sealed, key: key, iv: iv)) { error in
XCTAssertEqual(error as? RelayWire.Failure, .ciphertextFailedToAuthenticate)
}
}
// MARK: - Intent digest
func testIntentDigestMatchesTheGoAgent() throws {
let vectors = try loadVectors()
guard let intent = vectors["intent"] as? [String: String] else {
return XCTFail("the intent section is missing")
}
let digest = try RelayWire.intentDigest(
method: intent["method"] ?? "",
path: intent["path"] ?? "",
body: Data((intent["body"] ?? "").utf8)
)
XCTAssertEqual(hex(digest), intent["digest"])
// Formatting must not matter; content must.
let reordered = try RelayWire.intentDigest(
method: "post",
path: "/api/sms/send",
body: Data(#"{"content":"hello","phone_number":"+8613800000000"}"#.utf8)
)
XCTAssertEqual(hex(reordered), intent["digest"])
let edited = try RelayWire.intentDigest(
method: "POST",
path: "/api/sms/send",
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
)
XCTAssertNotEqual(hex(edited), intent["digest"])
}
// MARK: - Canonical JSON
func testCanonicalJSONMatchesTheGoAgent() throws {
let vectors = try loadVectors()
guard let pairs = vectors["canonicalJson"] as? [[String: String]] else {
return XCTFail("the canonicalJson section is missing")
}
for pair in pairs {
let input = pair["input"] ?? ""
let expected = pair["output"] ?? ""
let got = try CanonicalJSON.canonicalize(input)
XCTAssertEqual(String(decoding: got, as: UTF8.self), expected, "canonical JSON for \(input)")
}
}
func testCanonicalJSONRejectsEveryInputTheVectorsMarkInvalid() throws {
let vectors = try loadVectors()
guard let rejected = vectors["canonicalJsonRejected"] as? [String] else {
return XCTFail("the canonicalJsonRejected section is missing")
}
for input in rejected {
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
}
}
func testCanonicalJSONRejectsWhatTheProtocolWillNotHash() {
for input in ["{", #"{"a":1} trailing"#, #"{"a":+1}"#, #"{"a":.5}"#, #"{"a":NaN}"#,
#"{"a":1.0}"#, #"{"a":1e3}"#, #"{"a":01}"#, #"{"a":-0}"#] {
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
}
XCTAssertEqual(try CanonicalJSON.canonicalize(""), Data())
XCTAssertEqual(try CanonicalJSON.canonicalize(" "), Data())
}
/// The three key-sort shortcuts disagree, so this pins the one the protocol uses: UTF-8 byte
/// order. Swift's default `String` comparison uses Unicode collation, which would put "é"
/// before "z"; Foundation's `.sortedKeys` does the same.
func testCanonicalJSONSortsKeysByUTF8Bytes() throws {
XCTAssertEqual(
String(decoding: try CanonicalJSON.canonicalize(#"{"é":1,"z":2}"#), as: UTF8.self),
#"{"z":2,"é":1}"#
)
XCTAssertEqual(
String(decoding: try CanonicalJSON.canonicalize("{\"\u{10000}\":1,\"\u{FFFD}\":2}"), as: UTF8.self),
"{\"\u{FFFD}\":2,\"\u{10000}\":1}"
)
}
}
@@ -0,0 +1,161 @@
import XCTest
@testable import MultiSimAdmin
final class SettingsViewModelTests: XCTestCase {
@MainActor
func testLoadsRuntimeWithoutServerCapabilities() async throws {
let store = SettingsMemoryLogStore()
let model = SettingsViewModel(store: store)
await model.refresh()
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.2")
XCTAssertEqual(model.runtime?.buildVersion, "10")
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false)
XCTAssertNil(model.errorMessage)
}
@MainActor
func testClearsRuntimeLogsOnlyAndReportsRemovedCount() async throws {
let store = SettingsMemoryLogStore()
try await store.append(
runtime: [Self.runtimeLog()],
connections: [Self.connectionLog()]
)
let model = SettingsViewModel(store: store)
let removed = await model.clearRuntimeLogs()
XCTAssertEqual(removed, 1)
XCTAssertTrue(store.runtimeStorage.isEmpty)
XCTAssertEqual(store.connectionStorage.count, 1)
XCTAssertEqual(model.notice, "已清除 1 条运行日志。")
XCTAssertNil(model.errorMessage)
}
@MainActor
func testClearFailureSetsErrorWithoutChangingStorage() async throws {
let store = SettingsMemoryLogStore()
try await store.append(
runtime: [Self.runtimeLog()],
connections: [Self.connectionLog()]
)
let failing = FailingLogStore(base: store)
let model = SettingsViewModel(store: failing)
let removed = await model.clearRuntimeLogs()
XCTAssertEqual(removed, 0)
XCTAssertEqual(store.runtimeStorage.count, 1)
XCTAssertEqual(store.connectionStorage.count, 1)
XCTAssertEqual(model.errorMessage, "清除运行日志失败。")
XCTAssertNil(model.notice)
}
private static func runtimeLog() -> RuntimeLogEntry {
RuntimeLogEntry(
id: "runtime-1",
source: .delivery,
level: .info,
message: "测试日志",
occurredAt: Date()
)
}
private static func connectionLog() -> ConnectionLogEntry {
ConnectionLogEntry(
id: "connection-1",
instanceID: UUID(),
instanceName: "Node",
outcome: .success,
state: "online",
durationMs: 20,
observedAt: Date()
)
}
}
private final class SettingsMemoryLogStore: LogStore, @unchecked Sendable {
private let lock = NSLock()
private var runtimeItems: [RuntimeLogEntry] = []
private var connectionItems: [ConnectionLogEntry] = []
var runtimeStorage: [RuntimeLogEntry] {
lock.withLock { runtimeItems }
}
var connectionStorage: [ConnectionLogEntry] {
lock.withLock { connectionItems }
}
func runtimeLogs() async throws -> [RuntimeLogEntry] {
runtimeStorage
}
func connectionLogs() async throws -> [ConnectionLogEntry] {
connectionStorage
}
func append(
runtime: [RuntimeLogEntry],
connections: [ConnectionLogEntry]
) async throws {
lock.withLock {
var mergedRuntime = runtime
for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) {
mergedRuntime.append(existing)
}
runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt }
var mergedConnections = connections
for existing in connectionItems
where !connections.contains(where: { $0.id == existing.id }) {
mergedConnections.append(existing)
}
connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt }
}
}
func pruneConnections(before _: Date) async throws -> Int {
0
}
func clearRuntimeLogs() async throws -> Int {
lock.withLock {
let removed = runtimeItems.count
runtimeItems = []
return removed
}
}
}
private struct FailingLogStore: LogStore {
let base: SettingsMemoryLogStore
func runtimeLogs() async throws -> [RuntimeLogEntry] {
try await base.runtimeLogs()
}
func connectionLogs() async throws -> [ConnectionLogEntry] {
try await base.connectionLogs()
}
func append(
runtime: [RuntimeLogEntry],
connections: [ConnectionLogEntry]
) async throws {
try await base.append(runtime: runtime, connections: connections)
}
func pruneConnections(before cutoff: Date) async throws -> Int {
try await base.pruneConnections(before: cutoff)
}
func clearRuntimeLogs() async throws -> Int {
throw RepositoryError.databaseFailed
}
}
@@ -0,0 +1,26 @@
import XCTest
@testable import MultiSimAdmin
final class ThemeModeTests: XCTestCase {
@MainActor
func testLoadsPersistedThemeMode() throws {
let defaults = try XCTUnwrap(UserDefaults(suiteName: "ThemeModeTests-\(UUID().uuidString)"))
defaults.set(ThemeMode.dark.rawValue, forKey: "aiThemeMode")
XCTAssertEqual(ThemeStore(defaults: defaults).mode, .dark)
}
@MainActor
func testSelectionPersistsThemeMode() throws {
let suiteName = "ThemeModeTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
let store = ThemeStore(defaults: defaults)
store.set(.light)
store.set(.system)
XCTAssertEqual(ThemeMode(rawValue: defaults.string(forKey: "aiThemeMode") ?? ""), .system)
XCTAssertEqual(store.mode, .system)
}
}
@@ -0,0 +1,50 @@
import XCTest
@testable import MultiSimAdmin
final class TimestampTextTests: XCTestCase {
private let calendar = Calendar(identifier: .gregorian)
func testTodayOnlyShowsClockTime() {
let output = TimestampText.short("2026-09-14 08:05:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "08:05")
}
func testSameYearShowsMonthAndTime() {
let output = TimestampText.short("2026-03-02 08:05:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "03-02 08:05")
}
func testOtherYearFallsBackToDate() {
let output = TimestampText.short("2025-12-31 23:59:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "2025-12-31")
}
func testParsesEpochMilliseconds() {
XCTAssertEqual(TimestampText.date(from: "1767484800000"), Date(timeIntervalSince1970: 1_767_484_800))
}
func testParsesEpochSeconds() {
XCTAssertEqual(TimestampText.date(from: "1767484800"), Date(timeIntervalSince1970: 1_767_484_800))
}
func testZuluTimestampIsReadAsUTC() {
let iso = try? XCTUnwrap(TimestampText.date(from: "2026-01-04T00:00:00Z"))
XCTAssertEqual(iso, Date(timeIntervalSince1970: 1_767_484_800))
}
func testUnparseableTextIsKeptAsIs() {
XCTAssertEqual(TimestampText.short(" 刚刚 "), "刚刚")
XCTAssertEqual(TimestampText.short(""), "")
}
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
var components = DateComponents()
components.year = year
components.month = month
components.day = day
components.hour = hour
components.minute = minute
return calendar.date(from: components)!
}
}
@@ -0,0 +1,56 @@
import XCTest
@testable import MultiSimAdmin
final class UpstreamRouteTests: XCTestCase {
func testKnownRoutesUseExactPathsMethodsAndPayloads() throws {
XCTAssertEqual(UpstreamRoute.health.method, "GET")
XCTAssertEqual(UpstreamRoute.health.path, "/api/health")
XCTAssertEqual(UpstreamRoute.device.path, "/api/device")
XCTAssertEqual(UpstreamRoute.sim.path, "/api/sim")
XCTAssertEqual(UpstreamRoute.network.path, "/api/network")
XCTAssertEqual(UpstreamRoute.signalStrength.path, "/api/network/signal-strength")
XCTAssertEqual(UpstreamRoute.stats.path, "/api/stats")
XCTAssertEqual(UpstreamRoute.statsCpu.path, "/api/stats/cpu")
XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path,
"/api/sms/list?limit=100&offset=0")
XCTAssertEqual(UpstreamRoute.smsList(limit: 20, offset: 2, direction: "outgoing").path,
"/api/sms/list?limit=20&offset=2&direction=outgoing")
let login = UpstreamRoute.authLogin(password: "secret")
XCTAssertEqual(login.method, "POST")
XCTAssertEqual(login.path, "/api/auth/login")
let loginObject = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(login.body)) as? [String: String])
XCTAssertEqual(loginObject, ["password": "secret"])
let delete = UpstreamRoute.smsBatchDelete(ids: [2, 1])
XCTAssertEqual(delete.method, "POST")
XCTAssertEqual(delete.path, "/api/sms/batch-delete")
let deleteObject = try XCTUnwrap(
JSONSerialization.jsonObject(with: XCTUnwrap(delete.body)) as? [String: [Int]]
)
XCTAssertEqual(deleteObject, ["ids": [2, 1]])
}
func testSessionStoreIsIsolatedByInstanceAndOrigin() async {
let store = InstanceSessionStore()
let origin = URL(string: "http://192.168.1.10:3000")!
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: origin, cookie: "a")
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, origin: origin, cookie: "b")
let sameID = URL(string: "https://other.example")!
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: sameID, cookie: "c")
await store.clear(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
let first = await store.cookie(
for: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
origin: origin
)
let second = await store.cookie(
for: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
origin: origin
)
XCTAssertEqual(first, nil)
XCTAssertEqual(second, "b")
}
}
@@ -109,12 +109,14 @@ describe('instance API data source', () => {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'If-Match': '"rev-3"',
'X-Preparation-Id': 'prep-1',
'X-Confirmation-Token': token,
},
});
// A bodyless request must not claim a JSON body: the server's parser rejects the empty body
// with 400 before the confirmation headers are ever read, so deletion never happens.
expect(fetch.mock.calls[2]?.[1]?.headers).not.toHaveProperty('Content-Type');
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
// The confirmation is a bearer secret: it belongs in a header, never in a URL or a body.
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
@@ -89,9 +89,17 @@ interface JsonResponse {
readonly response: Response;
}
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
// Content-Type describes the body. Sending it on a bodyless request makes the server's JSON
// parser reject the empty body with 400 before the route runs — which silently killed every
// DELETE (deletion) and POST (test-connection) that carries its intent in headers alone.
const hasBody = init?.body !== undefined && init.body !== null;
const response = await fetch(url, {
...init,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers },
headers: {
Accept: 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
});
if (!response.ok) throw await safeError(response);
return {
+44
View File
@@ -0,0 +1,44 @@
# iOS Native 0.2.0
MultiSimAdmin 0.2.0 是面向 iPhone 的原生 IPA 版本。应用直接连接用户添加的 SimAdmin 节点,不需要先启动 8788 控制台。本文是历史发布说明,现行说明见 [docs/ios-native.md](ios-native.md)。
## 本次变更
- 底部导航精简为节点、消息、设置。
- 日志中心完整移入“设置 → 本地数据”,保留运行日志、连接日志、设备诊断、筛选和清理能力。
- 移除应用本地通知中心。通知依赖应用进程持续转发,不适合关闭后仍要求可用的场景。
- 设置页提供全局主题切换:跟随系统、浅色、深色,切换状态立即作用于全部页面。
- 隐私说明改为“隐私与数据”,明确节点信息、实例密码和登录会话的实际保存方式。
- 节点详情重排为更清晰的状态、连接、SIM、设备和会话分区。
- 应用图标重做为黑金风格。
- 节点总览展示硬件、控制、SIM、蜂窝链路状态。
- 分组头和节点详情头部使用固定尺寸指标卡,加载完成后不会跳变。
- 节点总览工具栏统一为 44pt 圆角图标按钮,补齐无障碍标签。
## 数据与安全
- 实例信息保存在应用沙盒内的本地数据文件。
- 实例密码保存在同一应用沙盒内的私有凭据文件,不使用 iOS Keychain。
- 登录会话只保存在进程内存中,应用关闭后清除。
- 应用仅连接用户添加的节点地址,允许 HTTP 是为了兼容局域网 SimAdmin;公网地址建议通过 HTTPS 反代暴露。
## 构建与安装
从仓库根目录构建:
```bash
./scripts/build-ios-ipa.sh
```
产物:
- `build/ipa/MultiSimAdmin.ipa`
- `build/ipa/MultiSimAdmin.ipa.sha256`
0.2.0 构建 3 校验值:
```text
1ddba7399d74705a7d31835dc6dff30c3b2fc3b3cada23c68eaa84a8fb2c832f MultiSimAdmin.ipa
```
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`。构建脚本不要求开发者证书,TrollStore 或自有签名流程可以后续处理。
+50
View File
@@ -0,0 +1,50 @@
# iOS Native 0.2.1
0.2.1 构建 6 在 0.2.0 的基础上做界面与图标重构,功能集合不变。应用的整体结构、页面能力、上游白名单和本地数据布局见现行说明 [docs/ios-native.md](ios-native.md),本文只记录本次发布的变化。
## 本次变更
- 应用图标重做:改用与 Web 端 favicon 同一套几何的扁平标记——金色 SIM 卡体加三格递增信号,左上保留 SIM 切角。放弃原先的照片级立体卡,29pt 设置图标下也能读清轮廓。
- 标签形状统一:详情页与日志页的状态标签改为与节点卡片一致的胶囊,不再一处圆角矩形、一处胶囊。
- 深色主题下标签可见性提高,色雾与描边权重上调,避免浅底可读、深底消失。
- 消息卡片去掉分隔线并压缩内边距,单屏可见消息数由 2.5 条提升到 3.5 条。
- 节点详情的分区卡片收紧行距,"设备"分区同一屏位由 3 行增加到 5 行。
- 节点详情头部指标收敛为 4 枚仪表(信号、延迟、CPU、内存)与一行身份摘要(号码、运营商、网络、IP),设备型号、固件、存储、运行时间下沉到分区列表;数据模块由 6 枚胶囊改为一条可用度读数,只在真有模块读不到时展开并点名。
- 批量选择勾选框移出实例名称区域,选中与未选中都不再压住文字。
- 批量选择工具栏统一为 34pt 图标按钮,全选、删除、退出三个控件等高。
- 分组编辑器改为点选即生效,去掉常驻禁用的"移出分组"按钮和说明文字。
## 文档同步
本次发布同时补齐了与实现脱节的说明文档:
- 新增 [docs/ios-native.md](ios-native.md),作为 iOS 应用的现行说明:导航、页面能力、上游接口白名单、本地数据与安全、构建与验证、已确认的移除边界。
- [docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md](superpowers/specs/2026-09-08-ios-native-ipa-design.md) 加上实现修订段,逐条标出被推翻的决策(五 Tab、通知中心、Keychain、GRDB/SQLite、操作与审计、设备模块范围、配色)。
- [docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md](superpowers/plans/2026-09-08-ios-native-ipa-foundation.md) 标记为已执行完毕,并记录持久化与凭据存储两处偏离。
- README 的 iOS 章节改写为当前版本、三个入口、移除能力与构建自检命令。
- 删除未被引用的旧图标参考稿 `apps/ios/Assets/AppIcon.svg`,图标参考只保留 `apps/ios/Design/AppIcon.svg`。
## 数据与安全
与 0.2.0 一致:实例信息保存在应用沙盒本地数据文件,实例密码保存在同一沙盒的私有凭据文件,不使用 iOS Keychain,登录会话只存在于进程内存。应用仅连接用户添加的节点地址。
## 构建与安装
从仓库根目录构建:
```bash
./scripts/build-ios-ipa.sh
```
产物:
- `build/ipa/MultiSimAdmin.ipa`
- `build/ipa/MultiSimAdmin.ipa.sha256`
0.2.1 构建 6 校验值:
```text
3bef3c678d0bb77a765d4920cb1fe917b82216f158e05b6f08315014e94bd8b7 MultiSimAdmin.ipa
```
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`。构建脚本不要求开发者证书,TrollStore 或自有签名流程可以后续处理。
+124
View File
@@ -0,0 +1,124 @@
# iOS Native 0.2.2
0.2.2 把「分组」这个实体从应用里彻底删掉,归类只靠标签;同时完成第二轮配色与图标重构,界面上
不再出现蓝色。构建 9 是一轮收尾打磨:修掉消息中心发不出去信的按钮遮挡,补齐按钮层级,统一缺数据
读数的写法,并把中性灰阶压成严格等色。构建 10 继续收细节:消息卡片右侧的空洞、批量标签胶囊的
假宽度、设置页读不出构建号、两张表单表的标题层级,以及图标石墨底的冷调。页面能力、上游白名单与
本地数据布局见现行说明
[docs/ios-native.md](ios-native.md),
本文只记录本次发布的变化。
## 本次变更
- 中性灰阶改为严格 R=G=B:此前所有灰底的蓝通道比红通道高 3~10 级,浅色主题下大面积文字与描边
会蒙一层冷调,截图取色仍落在蓝色波段。现在画布、卡片、控件、描边、文字全部等色,画面里
只剩红(危险)、橙(告警)、绿(正常)三类状态色。
- 延迟量表改为可读量:详情页延迟进度条此前恒为满格,38ms 和 900ms 画出来一模一样。现在与节点
卡片同一算法(越短越满,1s 归零),一根条真的在说延迟好坏。
- 单位与占位写法统一:卡片里的延迟从 `218ms` 改成 `218 ms`,与概览、详情同一写法;设置页日志
计数与日志中心的 HTTP 状态占位也从 `--` 收敛到 `—`。
- 表单里成对的控件同尺寸:标签输入框高 52,右侧加号按钮只有 44,两个控件并排时明显一高一矮。
现在加号与输入框同高同圆角。
- 详情页身份信息对齐方式统一:头部摘要的值此前左对齐成一列,下方分区卡片的值右对齐,同一屏两种
排法读起来像两张不同的表。现在两处都靠右。
- 修复消息中心无法发信:发送入口原先挂在页面底部的操作条上,而底部标签栏是用 `safeAreaInset`
贴在根视图上的,两者叠在同一位置,操作条整条被标签栏盖住,「发送消息」在真机上根本点不到。
现在发送与刷新一起放进导航栏右侧,节点总览、设置页同一区域同一套做法。
- 主按钮禁用态重新分级:禁用前的主要按钮与普通按钮像素一致,表单里「取消」和「保存实例」分不出
主次。现在禁用的主要按钮改用主色低透明度底加同色描边,并去掉投影,看得见但明显弱于可用状态。
- 消息行的删除按钮降噪:原先每条消息都带一枚红色实心方块按钮,一屏十几个红块。现在改成一个
红色线性图标,保留 44pt 点击区域,只在需要时提示危险语义。
- 缺数据读数统一为一根短横:概览与卡片读数此前混用「不可用」和 `--`,四列窄格里既容易被读成
故障结论,字号还会被压缩。现在一律用 `—`,并按占位弱色渲染。
- 应用图标金色再收一档:底部由深古铜提亮到香槟金,整块金属在高亮壁纸上不再往下沉成棕黄。
石墨底同步去掉 1 级的蓝通道偏差,1024 与 120 两档取色都不再落在蓝色波段。
- 分组概念移除:删除分组视图、分组编辑器与分组仓储,实例模型不再有 `groupId`。升级后首次启动
一次性删除 `Documents/instance-groups.json` 与偏好键 `fleet.groupMode`,本机不留死数据。
- 标签接管归类:新增「批量标签」表,批量选择后一次给多台节点贴同一个标签;每个标签显示
「未贴 / N/M / 全部」三态覆盖度,点击即在整批上加减,也可以在同一张表里新建标签。卡片与
标签条上的标签仍然可点,点一下即加入筛选。
- 配色重构,全应用零蓝色:主操作、选中态、进度填充一律用石墨墨色,黄铜只作图标点缀;颜色
只保留给状态语义(危险红、告警琥珀、成功绿),健康读数回到墨色,深色主题不再整屏泛绿。
浅色主题重做为中性冷灰画布加纯白卡片,深色主题为低明度石墨三层。
- 选中态可见性:主题切换块与状态图例胶囊原先用 10%~12% 色雾表示选中,浅色下几乎看不出来,
改为实心主色加 `onPrimary` 文字,两套主题都一眼可读,且选中不改变控件尺寸。
- 应用图标重做:放弃立体卡与投影,改扁平石墨底加黄铜 SIM 面板,三根底部对齐的递增信号条,
29pt 下轮廓仍可读;面板边长、圆角、切角与信号条间距可用环境变量调参后重新光栅化。
底色与插槽改为严格等值的 R=G=B 中性石墨(原先偏青绿,在亮色壁纸上会被读成蓝),金色
下压饱和度、提高明度跨度,从浅金到深古铜,去掉「芥末黄」的塑料感。
- 运行概览收紧:检测进度从读数格移进卡片副标题(`刚刚更新 · 已检测 6/6 台`);读数栅格固定
八格两行,温度与电量即使整群节点都没上报也保留「不可用」占位,栅格不再在七格与九格之间跳;
概览量表健康时用墨色,只在告警与危险时上色。
- 实例详情头部重排:四项仪表从四枚嵌套小卡改为两列发丝线栅格,读数在上、进度在下,缺数据时
保留空轨道,数据到位后卡片高度不变,修掉 0.2.1「加载完成后头部比下方窄一圈」的问题;身份摘要
去掉内嵌底色,改用顶部发丝线分隔。
- 字段名补全:详情页里 `name`、`disk_total_bytes`、`disk_available_bytes`、`percent` 四个键
此前直接把原始键名渲染到界面上,现补为「设备名称」「存储总量」「可用存储」「使用率」并排进
固定顺序。
- 标签与方向胶囊中性化:`AITag` 拆成两类——不传状态色时是中性归类胶囊(浅底、发丝描边、墨色
文字,可带图标),传状态色时才用色雾。详情页标签因此不再显示成近乎禁用的黑雾块,并与节点
卡片同一形状同一图标;消息中心的「收件 / 发件」由绿色改回中性胶囊配上下箭头,绿色重新只
表示健康状态。
- 批量标签表在没有任何勾选时给出「先在节点列表勾选要归类的节点」提示,不再留一行空白。
同时这段状态下标签胶囊不再渲染成 `Button`:系统禁用态会把整枚胶囊压成灰块,看起来像加载
失败,现按中性标签原样显示,只把「新建标签并贴上」的输入与按钮置灰并说明原因。
- 版本号显示去掉括号:设置页「应用信息」只显示 `0.2.2`。
## 构建 10
- 消息卡片右侧的空洞修掉:删除按钮此前和摘要挤在同一行 `HStack` 里,它自带 44pt 点击区,等于把
这一行撑高约 22pt,卡片下半截全是空白,一屏只能放三张。现在摘要行按正文列宽排版,删除按钮用
`overlay` 悬在右侧并溢出到卡片留白里,点击区仍是 44pt,一屏回到四张。
- 批量标签的胶囊改成贴合文字宽度:原先用 `LazyVGrid` 等宽栅格,短标签被拉到 112pt 一格,看起来
像整片禁用的灰块。现在换成流式布局(`AIFlowLayout`),胶囊只占文字加内边距,节点页的标签条
复用同一份实现,删掉了原来那份重复的布局代码。
- 设置页显示构建号:同一营销版本会连续出多个包,只写 `0.2.2` 分不清设备上装的是哪个。应用信息
现在是「版本 0.2.2 / 构建 10 / 系统 iOS 26.3.1」三行,全程不带括号。
- 两张表单表统一行内标题:「添加实例」和「发送消息」用大标题,标题下方留出一整条空白,而同类的
「批量标签」已经是行内样式。现在三张表一致。
- 节点卡片的空态文案不再自相矛盾:卡片下方的量表条确实会返回运行时数据,空态却写「节点未返回
运行信息」。改为「节点未返回设备信息」,与实际缺少的字段一致。
- 图标底色压暖:石墨底原先是数学意义上的中性灰(R=G=B),在 iOS 浅色壁纸旁仍会被对比成冷调,
这是用户反馈「图标是蓝色」的直接来源(旧包 0.2.1 的图标确实有 72% 像素落在蓝色波段)。现在
底色、面板阴影与插槽统一带 3~4 级暖偏移,取色为 `rgb(47,45,42)`,全图 0 蓝色像素。
## 文档同步
- 新增本文,[docs/ios-native.md](ios-native.md) 同步到 0.2.2:导航表去掉分组视图,节点总览与
实例详情两节按实况重写,本地数据表删掉 `instance-groups.json`,设计语言一节改为石墨与墨色
中性、颜色只留给状态、不含蓝色,偏离表新增分组一行。
- 仓库根 README 与 `apps/ios/README.md` 同步版本号、节点入口描述、目录说明与界面约定;界面
约定里「主操作只用一种蓝色」按本次结论改写。
## 数据与安全
与 0.2.1 一致:实例信息保存在应用沙盒本地数据文件,实例密码保存在同一沙盒的私有凭据文件,
不使用 iOS Keychain,登录会话只存在于进程内存。应用仅连接用户添加的节点地址,不上传任何数据。
本次新增的唯一数据动作是删除遗留的分组文件,不改动实例、标签与凭据内容。
## 构建与安装
从仓库根目录构建:
```bash
./scripts/build-ios-ipa.sh
```
产物:
- `build/ipa/MultiSimAdmin.ipa`
- `build/ipa/MultiSimAdmin.ipa.sha256`
0.2.2 构建 10 校验值:
```text
5018b9b4a1b236892b539746994176e1421a2cbe6ec24fdaac540d9bcbcf5fdd MultiSimAdmin.ipa
```
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`,未签名,
校验方式:
```bash
cd build/ipa && shasum -a 256 -c MultiSimAdmin.ipa.sha256
```
+206
View File
@@ -0,0 +1,206 @@
# iOS 原生应用说明(当前实现)
本文是 iPhone 应用的唯一现行说明,内容与仓库代码一致。历史设计稿和执行计划保留在
`docs/superpowers/` 下,其中已被实现推翻的决策在本文第 9 节逐条列出。
- 当前版本:`0.2.2`,构建号 10
- 承载分支:`ios-native-ipa`
- 运行要求:iOS 16.0 及以上,`arm64`,Bundle ID `fun.chick.multi-simadmin`
- 产物:未签名 `MultiSimAdmin.ipa`,由 TrollStore 或用户自有签名流程安装
## 1. 形态
独立 iPhone 应用。应用直接以 HTTP/HTTPS 访问用户添加的每个 SimAdmin 节点,不经过
8788 控制台,不含 WebView,不内嵌 Node.js,也不与 Web 端共享数据库或进程。Web 前端和
Node 控制面仍然独立存在,两者只共享上游接口契约。
## 2. 导航
底部固定三个入口,高度 62pt(`AITabBar`):
| 入口 | 页面 | 内容 |
| --- | --- | --- |
| 节点 | `FleetView` | 节点总览、运行概览、标签筛选与批量归类、批量选择、添加与编辑实例 |
| 消息 | `MessagesView` | 跨实例短信读取、按实例与方向筛选、搜索、发送、删除 |
| 设置 | `SettingsView` | 主题外观、应用信息、本地数据(进入日志中心)、隐私与数据、数据清理 |
实例详情 `InstanceDetailView` 由节点卡片进入;日志中心 `LogsView` 只出现在
「设置 → 本地数据」里,不占用底部入口。启动参数 `--tab-messages`、`--tab-settings`
可直达对应 Tab,`--tab-detail`、`--tab-logs` 只在 Debug 构建生效,供逐页截图验证使用。
## 3. 页面能力
### 节点总览
- 运行概览:在线率与异常节点两枚大字结论、状态分布条与图例、八格两行的群集读数(信号、延迟、
存储、流量、CPU、内存、温度、电量),可折叠,折叠时保留前四格。检测进度与更新时间写在卡片
副标题里(`刚刚更新 · 已检测 6/6 台`),不占读数格;整群节点没有上报的读数保留占位,统一写成
一根短横 `—`,避免在四列窄格里被读成故障结论;栅格不会在七格与九格之间跳动。
- 标签归类:标签是唯一归类手段,分组概念已在 0.2.2 移除。标签条支持多选叠加,胶囊上直接显示
命中台数,可单个取消或全部清除;卡片内的标签同样可点,点一下即加入筛选。
- 批量标签:批量选择后一次给多台节点贴同一个标签,每个标签显示「未贴 / N/M / 全部」三态覆盖度,
点击即在整批上加减;可在同一张表里新建标签。
- 搜索:匹配名称、地址、运营商与标签。
- 节点卡片:顶部四条量表(信号、延迟、CPU、内存)配发丝进度,下面是号码、运营商、设备、存储、
温度、电量、运行时间等指标,超出一屏时折叠为「另有 N 项指标」。健康读数不着色,只有越过阈值
才出现告警或危险色。
- 批量选择:工具栏为 34pt 图标按钮(全选、删除、退出等高),勾选列独立于名称区域,
选中态不遮挡文字。
- 实例增删改:地址、名称、标签、访问凭据;删除前确认,并一并清除本地登录信息。
### 实例详情
- 头部卡片:地址与连接状态、四项仪表(信号、延迟、CPU、内存)、身份摘要(号码、运营商、网络、
IP)、模块可用度读数、标签。四项仪表排成两列发丝线栅格,读数在上、进度在下,缺数据时保留
空轨道,数据到位后卡片高度不变。
- 数据模块:设备、SIM、蜂窝网络、系统资源、CPU、连接状态共 6 个分区,逐模块独立加载,
单模块失败只标记该模块,不影响其余分区。
- 敏感值(IMEI、ICCID 等)默认隐藏,需要显式开启显示,离开页面后自动恢复隐藏。
- 本机保存的配置:地址、凭据状态、标签,以及登录、登出、重新检测。
### 消息中心
逐实例读取 `/api/sms/list` 后在本地合并,不做跨设备中转:
- 列表按时间排序,显示来源实例、方向、号码、正文与相对时间。
- 支持按实例筛选、按上行/下行筛选、按号码或内容搜索。
- 发送与刷新一起放在导航栏右侧,页脚不再另挂操作条;发送写入目标实例,失败可重试;
单条删除走该实例的上游接口,行内只留一枚红色线性图标。
### 日志中心与设置
- 日志中心:运行日志统一时间线、连接探测记录(结果、错误码、耗时)、设备诊断快照;
支持按级别、来源、实例、结果筛选与关键字搜索,支持清理过期连接日志。
- 设置:主题外观(跟随系统、浅色、深色,即时作用于全部页面)、应用信息与系统版本、
本地数据统计、隐私与数据说明、清除运行日志(不可恢复确认)。
## 4. 上游访问
只允许固定白名单,不存在任意 URL 代理:
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/health` | 可达性与版本 |
| GET | `/api/device` | 设备信息 |
| GET | `/api/sim` | SIM 与号码 |
| GET | `/api/network` | 蜂窝网络与信号 |
| GET | `/api/network/signal-strength` | 信号强度明细 |
| GET | `/api/stats` | 系统资源 |
| GET | `/api/stats/cpu` | CPU 明细 |
| GET | `/api/connectivity` | 连接状态 |
| GET | `/api/sms/list` | 短信列表(limit、offset、direction) |
| POST | `/api/sms/send` | 发送短信 |
| POST | `/api/sms/batch-delete` | 批量删除短信 |
| POST | `/api/auth/login` | 使用实例密码换取会话 |
| POST | `/api/auth/logout` | 注销会话 |
请求超时 30 秒,单响应体上限 262144 字节,错误统一映射为请求非法、需要登录、权限受限、不支持、
超时、传输失败、响应非法七类可读文案。会话 Cookie 按实例隔离保存在内存中,并绑定创建时
的节点地址,地址改变后旧 Cookie 立即失效。应用不请求定位、相机、麦克风、通讯录或后台
刷新权限,不声明任何后台模式。
## 5. 本地数据与安全
全部数据落在应用沙盒 `Documents` 目录,无 Keychain、无 SQLite、无服务端上传:
| 文件 | 内容 |
| --- | --- |
| `instances.json` | 实例名称、地址、标签、修订号 |
| `credentials.json` | 实例访问密码 |
| `log-center.json` | 运行日志、连接探测记录、诊断快照缓存 |
0.2.1 及更早版本写过的 `instance-groups.json` 与偏好键 `fleet.groupMode` 在升级后的首次启动
一次性删除。主题偏好存在 `UserDefaults`(键 `aiThemeMode`)。登录会话只存在于进程内存,应用退出即消失。
密码与 Cookie 不写日志、不进导出内容。删除应用即彻底清除本机数据。允许 HTTP 明文访问是
为了兼容局域网 SimAdmin,公网节点应由用户自行通过 HTTPS 反代暴露。
## 6. 设计语言
界面不含蓝色。品牌色是石墨与黄铜:浅色画布中性冷灰 `#F4F5F5`、纯白卡片、墨黑主操作
`#151819`;深色画布 `#080909`、低明度石墨分层 `#131515` / `#1B1D1E`、近白主操作 `#EEF1F1`。
颜色只保留给状态语义——危险 `#C12E28` / `#F77169`、告警 `#AA5C0F` / `#E9A63C`、成功
`#107A56` / `#42C784`,健康读数一律用墨色,避免整屏泛绿或整屏泛红。选中态用实心主色加
`onPrimary` 文字,不使用低透明度色雾,浅色深色两套主题下都必须一眼可读。
控件令牌集中在 `AITheme`:主按钮高 48pt(紧凑 44pt、大号 56pt)、工具栏按钮高 44pt(宽 104pt)、
导航栏图标按钮 34pt 方形(圆角 11pt)、输入框高 52pt、卡片圆角 22pt、按钮圆角 16pt、标签为
28pt 胶囊。同屏并排控件必须等高,选中态不得改变控件尺寸,卡片内部不再嵌套卡片——并列读数改用
发丝线分隔的栅格,正文与标题不使用负字距,字距为 0。
## 7. 构建与产物
```bash
./scripts/build-ios-ipa.sh
```
流程:`xcodebuild -scheme MultiSimAdmin -configuration Release`(`generic/platform=iOS`)编译
可执行文件 → 手工组装 `Payload/MultiSimAdmin.app` → 用 `xcrun actool` 编译 `Assets.xcassets`
得到 `Assets.car` 并合并图标元数据到 `Info.plist` → `zip` 打包 → 生成 `.sha256`。产物:
- `build/ipa/MultiSimAdmin.ipa`
- `build/ipa/MultiSimAdmin.ipa.sha256`
脚本不需要证书与描述文件,不做任何签名改动;`Assets.car` 缺失时直接失败退出,避免发布没有
图标的包。
应用图标由脚本光栅化生成,不使用手工位图:
```bash
cd apps/ios && swift Design/generate_app_icon.swift \
Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
```
图标为扁平构图,不用立体卡与投影:石墨底、黄铜 SIM 面板(切角在左上)、三根底部对齐的递增
信号条,29pt 尺寸下轮廓仍然可读。`apps/ios/Design/AppIcon.svg` 是同构图参考稿,实际构建使用
由 `Design/generate_app_icon.swift` 光栅化出的 PNG,面板边长、圆角、切角与信号条间距都可用
环境变量调整。
## 8. 验证方式
```bash
cd apps/ios
swift build
swift test # 48 个用例
xcodebuild -scheme MultiSimAdmin \
-destination 'platform=iOS Simulator,id=<UDID>' \
-configuration Debug build
```
单元测试覆盖实例仓储与修订冲突、地址规范化与去重、标签归一化、上游路由白名单、会话隔离、
连接探测分类、短信解析与分页、设备快照解析、日志仓储与清理、主题模式持久化、时间相对显示、
版本号契约。界面改动在 iPhone 17 Pro 模拟器上以截图逐页核对浅色与深色两套主题,重点检查控件
等高、文字不溢出、按钮在深色下不消失。
## 9. 与设计稿的偏离
`docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md` 的以下决策已被实现推翻,该文档
只作为历史背景保留:
| 原设计 | 当前实现 | 原因 |
| --- | --- | --- |
| 底部五 Tab:节点/消息/通知/日志/设置 | 三 Tab:节点/消息/设置 | 应用内通知中心依赖进程常驻,关闭后不可用;日志属于低频排查,收进设置 |
| 通知通道、规则、投递队列、立即投递 | 未实现 | 同上,移除后不再保留只读残壳 |
| 实例密码存 iOS Keychain | 沙盒内 `credentials.json` | Keychain 在 TrollStore 环境下读写不稳定 |
| GRDB + SQLite 控制面库 | 沙盒内 JSON 文件 | 去掉第三方依赖,数据结构与备份语义更直观 |
| 操作目录、任务、审计、维护、备份 | 未实现 | 高风险写操作与常驻调度不在 iPhone 前台场景内 |
| 设备模块 13 项(含通话、eSIM、OTA、WiFi Calling、自动化) | 6 个只读模块 + 短信读写 | 上游只保留有稳定读取价值的接口,其余按需在后续版本评估 |
| 浅灰画布、青绿主按钮 | 深浅双主题、石墨墨色主操作、黄铜点缀 | 0.2.x 视觉重构结论,用户明确排除蓝色 |
| 实例分组(分组视图、分组编辑器、分组仓储) | 标签归类 + 批量标签 | 分组与标签职责重复,分组作为实体在 0.2.2 移除 |
## 10. 已确认的移除边界
不实现:应用启动后的周期轮询、后台任务与静默推送、Cron 与自动投递、自动备份与自动日志修剪、
控制台自更新、多用户与远程访问、全局访问密码。
保留:进入前台与下拉刷新触发的一次同步、页面可见时的按需刷新、用户主动发起的登录、检测、
发送、删除与日志清理。
## 11. 文档索引
- [docs/ios-native.md](ios-native.md):本文,现行说明
- [docs/ios-relay-architecture.md](ios-relay-architecture.md):双通道(内网直通 + 公网中继)设计稿,未实施
- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 构建 10 发布说明与校验值
- [docs/ios-native-0.2.1.md](ios-native-0.2.1.md):0.2.1 构建 6 发布说明与校验值
- [docs/ios-native-0.2.0.md](ios-native-0.2.0.md):0.2.0 发布说明
- [docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md](superpowers/specs/2026-09-08-ios-native-ipa-design.md):历史设计稿
- [docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md](superpowers/plans/2026-09-08-ios-native-ipa-foundation.md):地基执行计划(已完成)
+365
View File
@@ -0,0 +1,365 @@
# iOS 双通道方案:内网直通 + 中继到节点 agent
状态:设计稿(未实施)。目标是让 iPhone 在不出 VPN、不做内网穿透映射的前提下管理内网节点,
同时保留现有局域网直通能力。
## 1. 约束与结论
三条约束里只能同时满足两条,这是本方案的全部出发点:
1. 手机不依赖 VPN 客户端。
2. 内网不存在任何公网可解析的入口(端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 的公网
hostname 都算,判定标准是"打到这个名字能不能进内网",与连接建立方向无关)。
3. 中继服务器不参与信任。
因此拓扑必须是:手机 → 公网中继 → 节点 agent,其中 agent 只发起出站连接,且中继**看不懂**
也**改不动**流经它的指令。第 3 条不是靠"尽量安全"实现的,而是靠逐条签名 + 逐条加密实现的;
少了密码学,这套东西就只是一个更难用的 VPN。
```text
iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) <──出站 HTTPS 长轮询── 节点A agent ──HTTP──> 127.0.0.1:3000
每节点一条 agent 连接,agent 只被允许访问本机回环
```
`<──` 表示连接由 agent 侧发起,内网无任何监听端口。
> **实现修订(2026-09)**:agent↔中继采用**长轮询 HTTPS**而非 WSS。理由是实现约束而非协议偏好:
> agent 必须只用 Go 标准库交叉编译(WebSocket 的 framing/masking/ping-pong/close 要几百行且需要
> 自己测试),而"连接由内网侧发起、内网无监听"这一安全性质与承载协议无关。握手为两段式
> challenge-response(`/v1/agent/hello` → 签服务端随机数),投递为 `/v1/agent/poll`(25s 挂起)
> + `/v1/agent/result`,代码在 `simadmin-relay/agent/internal/transport/` 与 `relay/src/`。
## 2. 为什么是"每节点一个 agent"而不是"内网一个网关 agent"
用户提出的这个选择是正确的,且理由比运维便利性更强:**它把 SSRF 跳板的爆炸半径从整个内网收缩到
一台本来就已经物理接触的节点。**
网关型 agent 必须能访问 `192.168.1.0/24`,于是一条合法签名的指令可以拿它当跳板去扫内网任意
主机任意端口(`http://192.168.1.1:80/admin`、`http://169.254.169.254/...`)。要堵住就得再加
一份 origin 白名单,而白名单本身又是一个需要人维护、容易配错的安全边界。
每节点 agent 则可以把出口**硬编码为回环**:agent 启动时校验目标 host 必须是 `127.0.0.1` 或
`::1`,否则拒绝启动。这条规则不需要配置、不需要白名单、不可能配错。
代价是 agent 数量 = 节点数量,每台都要装、都要配对、都要保活。以及:如果某台节点上的 SimAdmin
并不在回环上(比如跑在另一台机器上),本方案对它不适用,需要退回网关型并显式配置 CIDR 白名单。
## 3. 密码学信封
密钥(每设备一套,每节点一套):
| 密钥 | 算法 | 用途 |
|---|---|---|
| `K_app_sign` | Ed25519 | 手机签名请求信封,agent 验签 |
| `K_app_kx` | X25519(静态) | 手机接收响应时的 DH 一方 |
| `K_agent_sign` | Ed25519 | agent 签名响应,手机验签 |
| `K_agent_kx` | X25519(静态) | agent 接收请求时的 DH 一方 |
请求信封(手机 → 中继 → agent):
```jsonc
{
"v": 1,
"node": "nd_7f3a...",
"req": "<uuid4>",
"app": "ap_5b1d...",
"ts": 1727289600,
"alg": "X25519-ED25519-AES256GCM-HKDFSHA256",
"ek": "<base64 32B>", // 本次请求的临时 X25519 公钥
"p": "<base64 AES-256-GCM>",
"s": "<base64 Ed25519>"
}
```
短字段名是刻意的:这是三语言共享的线上格式,Go / Node / Swift 各自的结构体标签必须逐字一致,
而长名在手写解码器里最容易抄错。字段名以 `agent/internal/wire/wire.go` 为权威。
`s` 覆盖 `v,node,req,app,ts,ek,p` 的规范化字节串(固定字段序 + 每个字段 uint32be 长度前缀,
不做 JSON 字符串拼接签名)。这一点是实质性的:签名包含 `node`,中继就无法把一个对节点 A 有效
的请求改投给节点 B;签名包含 `req` 与 `ek`,就无法做响应错配或临时公钥替换;签名包含 `app`,
中继就无法把一台手机的流量改标成另一台的。响应信封用另一个域字符串 `msa-relay-v1-resp` 且
**不含 `app`**,因此两个方向的签名不能互换。
规范化字节串的确切定义(含 `v`/`ts` 也带 `00000008` 长度前缀这一条)以
`contracts/test-vectors.json` 为准,该文件由 `agent/cmd/msa-vectors` 生成,并由 Node
(`test/wire.test.js`)与 Swift 各自重算。三语言互不共享代码,因此它们的字节一致是真实证据,
不一致则说明编码漂移,而不是"某一端坏了"。
`alg` 只有唯一合法值,且在任何其他字段之前校验。未知值一律硬错误,不做算法协商——降级攻击
就发生在协商里。
`payload` 解密后的内层:
```jsonc
{ "m": "POST", "p": "/api/sms/send",
"intent": "<base64 sha256(upper(trim(m))|p|canonicalJSON(body))>",
"body": "<base64 原始请求体>" }
```
`body` 以原始字节透传,agent 不解析也不重新序列化:一旦重新编码 JSON,intent 所覆盖的字节就
和真正转发上去的字节不同,确认语义随之失效。`p` 携带完整路径与查询串,agent 不做路径拼接,
因此不存在来自中继的路径穿越面。
`ts` 由信封层统一负责新鲜度(`|now - ts| > clockSkewSeconds` 即拒),内层不再带 `deadline`
字段:两套过期语义只会互相冲突,而且信封层的那一个受签名保护,内层的那一个不受。
密钥派生:请求方向 `HKDF(X25519(appEphPriv, K_agent_kx.pub), salt=nodeID, info="msa-relay-v1-req")`,
agent 侧用 `X25519(K_agent_kx.priv, ek)` 得到同一密钥;响应方向由 agent 每次新生成临时密钥对,
把公钥放在响应信封的 `ek` 里(同样被签名覆盖),与**手机本次请求的 `ek`** 做 DH——即响应方向是
临时×临时,手机不需要在配对后保留可用的 `K_app_kx` 私钥于响应路径(第 11 节第 5 项的缓存方案
因此已由实现取代)。
**这里的前向安全性质要说准,不能含糊。** 请求方向是"临时-静态"单消息 DH(HPKE base 模式的
同一形状),因此只对**发送方**(手机)的长期密钥泄露是前向安全的,对**接收方**(agent)的不是;
响应方向(实现后)双向临时,任一方长期密钥泄露都不能解密历史响应:
- 请求方向的 `ek` 以明文出现在被签名的信封里。日后 `K_agent_kx.priv` 泄露的人,可以用它和录到的
`ek` 直接算出历史请求的密钥,从而读出历史请求内容。
- 反过来,`appEphPriv` 与 `agentEphPriv` 用后即弃且从不上传线,所以只泄露"发送方"的长期密钥
不足以解密历史。
> **实现修订**:响应方向已改为对手机**本次请求的临时 `ek`** 加密,无需额外往返,因此上段"建议的
> 缓存方案"对响应方向已无必要。残留的弱化只剩请求方向:要让请求也对 `K_agent_kx` 泄露前向安全,
> 才需要手机缓存 agent 的当前临时公钥(bootstrap 第一条除外)。是否值得做,留待 P4 后再定。
GCM IV 取 `HKDF(S_req, salt=reqID, info="iv")` 的前 12 字节,显式派生而不是全零。虽然每个请求
都用一次性密钥、全零 IV 在实践中不会重用,但一旦有人日后把 `S_req` 改成按会话复用(很常见的
"优化"),全零 IV 立刻变成 nonce-reuse 灾难;显式派生把这条退路堵死。
重放防护:agent 维护 `reqID` 的 LRU(建议 4096 条)拒绝重复;拒绝 `|now - ts| > 120s` 的请求;
不执行过期请求。不需要额外的 nonce 字段——`req` 是 uuid4 且被签名覆盖,中继无法把一个有效请求
的 `req` 换掉再投,因此它本身就是唯一性来源。
时钟偏移是本方案最容易被忽略的故障源:节点设备如果长期离线且时钟漂移,表现为"所有指令都被拒",
所以 agent 日志必须把拒绝原因写成可区分的三类(签名无效 / 重放 / 超时),手机侧错误文案也要能
区分,否则排查会非常痛苦。
## 4. agent 侧的授权语义
**中继不得授权任何事。** 危险写的确认必须由手机产生、由 agent 校验,因为中继在本方案里是
不可信方。这与现有控制台的做法不同:`server/proxy/confirmations.js` 的一次性令牌是**服务端签发**
的,在"服务端可信"的前提下成立;搬到中继模式后签发方变成不可信方,语义就空了。
替代做法:危险写要求内层带 `confirm.intent`,其值为用户明确点击确认后计算的
`sha256(method|path|规范化 body)`。agent 重算并比对,因此 intent 与实际操作强绑定——中继无法
拿一个"确认过发短信"的 intent 去执行"改 APN"。同时 agent 本地限流(默认危险写 6 次/分钟)并
落审计日志。
诚实说明弱化点:这防的是**中继和外部攻击者**,防不了手机本身被控。手机丢了要靠中继侧吊销设备
(第 5 节),而吊销是"拒绝投递",agent 侧仍会接受该密钥的指令,直到你重新配对。要做到 agent
侧即时吊销,必须让 agent 可编辑受信设备密钥集,并在每次连上中继时拉一次密钥集快照——建议纳入
第一期,因为它决定了"丢手机"这件事的真实后果。
## 5. 中继
职责只有四件:鉴权、投递、关联、超时。它没有解密能力,也不需要节点凭据。
- 设备鉴权:配对时下发的长随机 bearer token(仅用于访问控制与滥用限制,**不构成信任**)。
- agent 鉴权:bearer token + 用 `K_agent_sign` 签一个服务端随机数。仅偷到 token 无法冒充 agent。
- 注册表:`nodeID -> 唯一活跃会话`(长轮询挂起槽,后到的 agent 握手顶掉前一个);agent 断线即标记不可达,不做请求排队等待重连。
- 邮箱:每节点一条有界 FIFO(深度建议 8),`reqID` 关联请求与响应,30s 超时向上返回
- 错误一律用仓库既有的 RFC 9457 信封 `ProblemDetails`(`packages/contracts/src/errors.ts`),
不另造形状;其中 `requestId` 直接取 `reqID`,这样中继日志、控制台 request coordinator 与手机
三方可用同一个 id 串起来查。超时返回 `504` + `code: "relay_agent_timeout"`,不重试。有界队列
是刻意的:宁可明确失败,也不要让手机以为"发出去了"。
- 日志:只记元数据(nodeID、reqID、方法类别、字节数、时延、结果码)。这是本方案无法消除的泄露面。
## 6. iOS 侧改造
### 6.1 端点模型
现在 `Instance.origin: URL` 是非可选的,并且同时承担三件事:请求目标、去重键
(`FileInstanceRepository` 用 `$0.origin == origin` 抛 `duplicateOrigin`)、会话 Cookie 的绑定域
(`InstanceSessionStore` 按 `(UUID, origin)` 存,origin 变了即失效)。中继模式下这三个职责都要
重新安置,所以必须引入端点抽象而不是给 `Instance` 加一个可选的 `relayNodeID` 字段:
```swift
public enum InstanceEndpoint: Codable, Equatable, Sendable {
case direct(URL)
case relay(RelayEndpoint)
}
public struct RelayEndpoint: Codable, Equatable, Sendable {
public var relayBaseURL: URL
public var nodeID: String
public var agentSigningKey: Data // Ed25519 pub, 32B
public var agentKxKey: Data // X25519 pub, 32B
public var deviceToken: Data // 中继 bearer
}
```
迁移:`Instance` 的 `Codable` 改为容忍旧数据——`init(from:)` 先试 `endpoint`,缺失时把
`origin` 读成 `.direct(origin)`。去重键换成 `endpoint.dedupKey`(直通为 origin 绝对串,中继为
`relay:<nodeID>`)。写盘时统一输出 `endpoint`。旧包升级后行为不变,且不会把已有节点判成重复。
`credentialConfigured` 的语义要按模式分叉:直通表示"本机存了该节点密码",中继表示"agent 侧已
配置凭据"。两者不能混用同一个文案,否则用户会以为中继模式下手机里还有密码。
### 6.2 传输层
```swift
public protocol NodeTransport: Sendable {
func send(_ route: UpstreamRoute, endpoint: InstanceEndpoint) async throws -> UpstreamResponse
}
```
- `DirectTransport` 就是现有 `SimAdminClient` 的行为,含逐节点登录与 Cookie 管理,**代码路径
与今天完全一致**,不引入任何行为差异。
- `RelayTransport` 只做信封构造、签名、加密、HTTPS 投递、验签、解密;不碰 Cookie,节点密码
从不出手机(agent 自己登录 `127.0.0.1`)。
`UpstreamRoute` 无需改动:它已经是 `method + path + body` 的纯数据描述,天然可序列化进信封。
这是现有设计里少数直接能复用的部分。
### 6.3 模式选择
建议**显式优先**:每个实例一个明确模式,"自动"后置。理由是自动回退(直通短超时 1.5s 后转中继)
在非内网时会把每个读的延迟翻倍,且失败原因变得难以归因——用户看到的是"时快时慢",而不是
"不在内网"。自动模式应当等到中继跑稳、且能拿到网络可达性信号之后再上。
界面必须做两件事,否则运维上不可接受:
1. 节点卡片显示通道徽标(直通 / 中继)。没有它,"这台为什么慢"无法回答。
2. 错误分层。至少区分「中继不可达」「agent 离线」「信封被拒(验签或重放)」「节点返回错误」
四类。现有 `UpstreamError` 有 7 个分支,但传输层失败全部塌进 `transportFailed` 与
`invalidResponse`,无法表达上面这四类的区别;节点返回错误还能靠状态码分支,中继自身的失败
则完全没有对应分支。中继模式需要新增独立分支,不要复用 `transportFailed` 兜底,否则用户
看到的永远是同一句"请求失败"。
错误码 vocabulary 建议落在 `packages/contracts` 里以 const 数组枚举(照 `CAPABILITY_STATUSES`
的现有写法),由中继、agent、iOS 三方共享。现状是 `ProblemDetails.code` 在 OpenAPI 里只是
无约束 `string`,全仓没有枚举,所以这是新增而非复用——但必须在第一期就枚举出来,否则三方
各写各的字符串,跨端排查立刻失效。
### 6.4 密钥存放
按既有约定,设备私钥与节点凭据同放应用沙盒文件,不进钥匙串。需要明确记录的代价:沙盒文件在
越狱设备上可被读取,Secure Enclave(`SecureEnclaveKey`)能消除这一点,但与"不用钥匙串"的
既定决策以及 TrollStore 签名流程冲突。**这是本方案唯一悬而未决的安全取舍,需要你定。**
好消息:Ed25519 / X25519 / AES-GCM / HKDF 全部在 CryptoKit 内,iOS 16 起可用,**零新增依赖**。
## 7. 配对流程
利用"配对时手机一定在内网"这个事实,不需要扫码:
1. 节点上启动 agent,它临时在内网接口监听 `:3001` 的管理端口,仅开放配对与自毁两个端点。
2. 手机在直通模式下已经能访问该节点,于是把 `{appSignPub, appKxPub, deviceName}` POST 到
`http://<节点内网IP>:3001/provision`,以节点密码作为共享秘密完成鉴权。
3. agent 返回 `{nodeID, agentSignPub, agentKxPub, relayURL, deviceToken}`,手机存为该实例的中继端点。
4. agent 关闭管理监听,此后只出站连中继。
第 4 步是必须的:管理端口长期开着就等于在本方案之外又开了一个能注入受信设备的洞。
> **实现修订(2026-09)**:窗口由 `msa-agent --pair` 显式开启(默认 `:3001`),且第 4 步由三个
> 独立关闭条件保证——首次成功配对即关、失败尝试达 10 次即关、10 分钟无人配对即关。`appKxPub`
> 仍按原样接收(线上格式不变),但响应方向改用手机请求临时 `ek` 后 agent 并不使用它。设备的中继
> bearer 由 agent 在配对时生成并持久化于配置文件(0600 原子写),中继侧设备注册改为 agent 握手后
> POST `/v1/agent/devices` 全量上报:中继重启可自愈,节点 destroy 设备列表后的下一次握手即吊销
> 该节点上报的全部 bearer。操作员手写的 `MSA_DEVICES` 条目为 static,任何 agent 上报不得覆盖。
> 代码:`agent/internal/pair/`、`relay/src/auth.js#registerDevices`。
## 8. 策略文件必须共享,不能各自实现
`server/proxy/policy.js` 的 `DEFAULT_READ_PATHS` / `DEFAULT_WRITE_PATHS` 是本项目里最有价值的
现成资产,但它是 JS。如果 agent 用 Go/Rust 写,就会变成"两份手抄的白名单",而两份策略必然
漂移——漂移的后果是同一操作在网页控制台被拒、在手机中继下被放行,这类 bug 极难发现。
因此第一步应当是把策略抽成数据文件(`packages/contracts/src/proxy-policy.json`),控制台与
agent 都从它加载,并加一个测试断言两侧解析结果一致。这一步很小,但它决定后面所有安全性质
是否真的成立。
## 9. 实施阶段
| 阶段 | 内容 | 依赖 |
|---|---|---|
| P1 | 契约先行:策略抽为共享 JSON、枚举中继错误码、把 `FRESHNESS_STATUSES` 引到 iOS;加双端一致性测试 | 无 |
| P2 | agent:出站长轮询、信封验签解密、策略执行、回环硬校验、审计日志、本机登录、配对端点 | P1 |
| P3 | 中继:设备/agent 鉴权、注册表、有界邮箱、超时、元数据日志 | P1 |
| P4 | iOS:`InstanceEndpoint` + 迁移 + `NodeTransport` 抽象 + 密钥与配对 | P2,P3 |
| P5 | iOS:通道徽标、分层错误、危险写确认、吊销与轮换入口 | P4 |
| P6 | 文档、部署脚本、渗透自查(重点:回环硬校验、重放、队列堆积) | P5 |
P2/P3 可并行。P4 是唯一动到现有 iOS 数据模型的部分,风险集中在迁移,建议单独一个分支并配
旧数据升级测试。
> **实现进度(2026-09)**:P1–P5 已落地,P6 部分完成。
>
> - P1:`contracts/agent-policy.json` + `app-routes.json` 钉死策略面,`relay/src/errors.js` 枚举
> 中继错误码,iOS 侧 `RelayErrorCode` / `AgentOutcome` / `SnapshotFreshness` 与之一一对应;
> `scripts/export-app-routes.swift` 生成 vendored 路由表,iOS 的 `AppRoutesContractTests`
> 反向断言它仍与应用可构造的路由一致(此前这条链中间有一个手抄环节)。
> - P4:`InstanceEndpoint` 取代 `Instance.origin`,旧数据由 `init(from:)` 读成 `.direct`,
> `FileInstanceRepository` 只写新形状;迁移由 `InstanceEndpointMigrationTests` 用字面量旧 JSON 覆盖。
> `NodeTransport` 抽象 + `DirectTransport`/`RelayTransport`/`NodeTransportRouter`。
> `RelayWire` 在 Swift 侧重算 `contracts/test-vectors.json`(见下)。
> - P5:卡片通道徽标与 freshness 副标题、`ErrorMessage.describe` 的四类以上分层文案、
> 危险写 intent 由 `RelayTransport.encode` 在发送瞬间生成、配对 sheet(`RelayPairingView`)。
> 吊销入口仍未做:目前需要在内网对节点跑一次 `--pair --destroy`。
> - P6:部署文档(`simadmin-relay/docs/deployment.md`)与渗透自查(`test/adversarial.test.js`,
> 12 项)已落地。自查找出一处真实缺陷并已修复:中继的 pending 表只按 `reqID` 索引,
> `/v1/agent/result` 未校验提交方是否该请求所属节点的 agent,因此被控节点只要知道另一个节点的
> 在途 `reqID` 即可抢先应答,把它变成 orphan 并让合法应答被丢弃。修复方式是在
> `registry.resolve` 上增加 nodeID 校验(`relay/src/registry.js`)。
>
> 另有一个**端到端测试**(`test/e2e.test.js`):它编译并运行真实的 Go agent,经 TLS 接到真实
> 中继,由模拟手机发起签名请求。它一次抓出三个"单元测试全绿、合起来坏了"的缺陷,值得逐条记录,
> 因为三个都是接缝问题:
>
> 1. `transport.Client` 把会话 token 写进了 `c.token`(bearer 字段),于是轮询拿会话 token 当
> bearer 发,中继一律 401;症状是 agent 连上后无限重连,日志说"凭据被拒"。两个凭据现在分开。
> 2. `main.loop` 每个周期都重新握手。中继每节点只保留一个会话,新握手顶掉旧的并丢弃其挂起的
> 轮询,于是两次握手之间到达的请求被投进已不存在的会话、以"agent 离线"超时,而 agent 全程
> 在线。改为仅在会话失效或传输失败时重新握手。
> 3. `auth.registerDevices` 把"已存在"一律跳过,随后又被"本轮未声明"的回收循环删除,所以节点的
> 第二次握手会注销自己刚注册的设备。改为本节点已持有即确认(幂等)——重连与重握手是常态。
>
> 结论:契约与单元测试能证明两侧各自正确,证明不了两侧接得上。上线前必须跑一次端到端。
>
> 另:agent 支持 `relayCAFile`(PEM 钉 CA,**替代**系统信任库而非关闭校验),端到端测试用自签
> 证书 + 钉 CA 走的就是这条真实校验路径。
>
> **Swift 侧签名不是逐字节可复算的**:CryptoKit 的 Ed25519 签名是随机化的(同一密钥同一消息
> 两次签名结果不同),Go 与 Node 用的是 RFC 8032 确定性实现。因此 `RelayWireTests` 校验的是
> 更强的性质——双方各自验证对方产出的签名,以及签名输入与向量中的规范化字节完全相等——而不是
> 签名本身相等。这一点已实测确认:CryptoKit 能验证 Go 生成的签名,Go 也能验证 CryptoKit 生成的
> 签名。
## 10. 已知代价
- 中继被攻破 = 服务不可用(拒绝投递),不等于数据泄露。这是本方案换来的东西。
- 元数据仍然泄露给 VPS:谁在何时对哪个节点做了哪类操作、响应多大。要连这个都不要,只能回到 VPN。
- 读操作两跳延迟,实时性弱于直通,节点状态必须能表达"这是多久以前的"。仓库里已有
`FRESHNESS_STATUSES`(`fresh / stale / expired / unknown`,`packages/contracts/src/instances.ts`),
但 iOS 侧目前**完全没有**这套 vocabulary,只有概览副标题一句"刚刚更新"。中继模式必须把
freshness 落到 iOS 数据模型与卡片上(沿用同一组四个状态值,不要另起名),否则用户无法判断
一个"在线"是当下的还是 40 秒前的——这在中继模式下是实质性误导。
- 每台节点多一个常驻进程,Android 上会被 Doze 杀,需要前台服务与电池优化豁免;这是运维负担,
不是理论问题。
- 内网仍无任何公网入口,但**出站被完全掐断的环境(仅白名单出网的内网)会让 agent 无法工作**。
## 11. 已确认项与仍待确认项
已确认(据此实现):
1. 节点设备为 **ARM 架构 Debian**,且 SimAdmin 与 agent 同机运行。因此 agent 是 Go 静态二进制,
交叉编译 `linux/arm64` 与 `linux/arm`,回环硬校验(第 2 节)对所有节点成立,无需 CIDR 白名单。
2. 设备私钥放在**应用沙盒文件**,不进钥匙串(6.4)。代价已记录,此处不再悬置。
3. 8788 控制台与中继**不同机**,理由见第 2 项旧表:同机会把控制台推到公网口。
仍待确认:
4. 第 10 节的元数据泄露(谁在何时对哪个节点做了哪类操作)是否接受。这是本方案不可消除的泄露面。
5. ~~双临时缓存方案~~ **已定**:响应方向实现为对手机请求临时 `ek` 加密(双向临时),无需缓存;
请求方向维持临时-静态,即明确接受"`K_agent_kx.priv` 泄露可解密历史请求"。若要连请求方向也
补齐,再评估缓存方案(代价见第 3 节修订注)。
6. agent 侧即时吊销(每次连上中继拉一次受信设备密钥集快照)是否纳入第一期。不纳入则"丢手机"
的真实后果是:中继能拒绝投递,但 agent 仍接受该密钥的指令直到重新配对。
## 12. 本方案明确不做的事
- 不做端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 公网 hostname。
- 不让中继持有或代管节点密码。
- 不在 iOS app 内嵌 VPN 网络扩展(需要 Apple 签发的 entitlement 与带 app-extension 的
bundle,当前签名流程不适合)。
- 不引入"自动选路"作为默认行为。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
# iOS 原生 IPA 改写设计
日期:2026-09-08
> **实现修订(2026-09-14,0.2.1 build 6)**
>
> 本文是改写启动时的设计稿,保留作为历史背景。以下决策在实现过程中被推翻,现行事实以
> [docs/ios-native.md](../../ios-native.md) 为准,正文中的旧描述不再单独改写:
>
> - 底部导航为**节点、消息、设置**三个入口,不是本文的五个 Tab。
> - **应用内通知中心整体移除**:通知依赖常驻进程转发,应用关闭后无法工作,因此通道、规则、
> 投递队列、手动投递均不实现。
> - **日志中心不是独立 Tab**,收纳在「设置 → 本地数据」内。
> - **不使用 iOS Keychain**:TrollStore 环境下 Keychain 读写不稳定,实例密码改存应用沙盒
> `Documents/credentials.json`,仅内存持有会话 Cookie。
> - **不使用 GRDB/SQLite**:本地数据改为四个 JSON 文件(`instances.json`、
> `instance-groups.json`、`credentials.json`、`log-center.json`),不引入第三方依赖。
> - **操作目录、任务、审计、维护、备份、恢复未实现**:iPhone 前台场景不承载高风险写操作与
> 调度型能力,本文对应章节按未落地处理。
> - 设备模块落地为 6 个只读分区(设备、SIM、蜂窝网络、系统资源、CPU、连接状态)加短信读写,
> 本文列出的通话、eSIM、OTA、WiFi Calling、自动化历史未纳入当前版本。
> - 配色从「浅灰画布 + 青绿主按钮」改为深墨蓝与金色的品牌对,浅色用中性冷灰画布,主操作为蓝色。
## 目标
把 multi-simadmin 的管理能力改写为独立 iPhone 应用,而不是为现有 Web 服务做一个远程客户端或 WebView 壳。应用面向 iOS 16+ 和 TrollStore 安装环境,构建流程产出未签名 IPA;Apple 证书、开发者账号、TestFlight 和上架签名不在本项目范围内。
原生应用直接连接用户配置的各 SimAdmin 实例,本地承担实例注册、连接状态、消息归档、通知队列、任务历史、审计、日志和维护能力。不要求用户先运行 8788 控制台服务。现有 Web 前端与 Node API 保持不变,直到 iOS 版本验证后再另行决定发布关系。
## 已确认决策
- 形态:SwiftUI 原生应用。
- 最低系统:iOS 16.0。
- 构建产物:`MultiSimAdmin.ipa`,默认不签名,后续由用户用 TrollStore 或自有证书处理。
- 功能范围:优先保持现有控制台与 Hub 能力,但移除不适用于 iPhone 前台的定时/常驻能力。
- 全局访问密码:移除;实例自身的登录凭据仍保留并安全存储。
- 现有 UI 风格:移植为 iOS 原生组件,不照搬浏览器布局。
## 架构
新增 `apps/ios`,包含独立 Swift 应用。结构分为五层:
1. **App 层**:SwiftUI 应用入口、导航、生命周期和全局错误提示。
2. **Presentation 层**:节点、消息、通知、日志、操作、设置等 SwiftUI 视图与 view model。
3. **Domain 层**:实例、连接、消息、通知、操作、审计、维护等领域服务和值类型。
4. **Upstream 层**:`URLSession` 访问 SimAdmin 实例,管理实例会话 Cookie、超时、响应解析和安全路径白名单。
5. **Persistence 层**:SQLite 持久化本地控制面数据;实例密码等敏感值使用 iOS Keychain。
数据库采用 GRDB 管理迁移与查询,避免手写 SQLite C API。凭据使用 Keychain 的应用私有可迁移项,不进入数据库明文、日志、备份预览或导出文件。
应用不内嵌 Node.js。iOS 版本的消息、通知、任务和审计语义按现有控制面模型重新实现,保留核心字段和用户可见行为。
## 导航与界面
iPhone 主导航使用底部 Tab:
- **节点**:总览、筛选、批量选择、添加/编辑实例、实例详情和设备模块。
- **消息**:跨实例短信归档、会话、发送、删除、手动同步和发送队列。
- **通知**:通道、规则、队列、投递日志和手动投递/重试。
- **日志**:运行日志、连接日志、诊断和审计。
- **设置**:实例管理入口、分组/标签、操作目录、维护、备份、应用信息。
界面移植现有设计语言:
- 画布使用浅灰 `#f3f5f8`,主要内容使用白色卡片和柔和描边。
- 顶部使用深色品牌栏,内容区保持浅色高对比。
- 主操作按钮使用现有青绿色系统,危险操作使用现有红色系统。
- 统一普通、紧凑、大号控件高度和圆角;同屏并排的主要操作必须尺寸一致。
- 使用 SF Symbols 表达刷新、筛选、添加、删除、发送、密钥和状态,不使用文字包住所有图标。
- 卡片、表格、表单和确认弹窗按 iPhone 动态字体、安全区、横竖屏和 Assistive Access 语义适配。
## 功能映射
### 实例与节点
- 添加、编辑、删除实例,支持名称、地址、标签、分组和实例密码。
- 连接测试、登录、登出、在线/离线/需登录/降级状态和快照新旧程度。
- 批量选择、批量测试、批量删除、分组筛选、标签筛选、状态筛选和搜索。
- 节点资源摘要、手机号、温度、版本、新鲜度和最新短信预览。
- 删除实例前先尝试释放 Hub 绑定;无法访问时按现有规则区分“跳过释放”与“失败”。
### 设备模块
实例详情保留上游设备能力模块:总览、蜂窝、SIM、通话、eSIM、WiFi Calling、设备网络、OTA、消息、通知、自动化历史、配置和备份。每个模块按现有上游端点读写,使用统一的加载、空态、无权限、超时和确认流程。
高风险动作沿用操作目录中的风险等级和确认文案;系统重启、基带重启、服务重启等动作必须明确显示目标实例。
### 消息
- 直接拉取实例短信列表、会话、统计和详情。
- 发送短信、批量删除、清空、按方向筛选和分页。
- 本地归档跨实例消息,保留方向、号码、内容、时间、状态、来源实例和同步时间。
- 发送队列保留离线重试记录,但只在前台刷新、进入消息页或用户点击“发送”时处理。
### 通知
- 管理通知通道、规则、队列和投递日志。
- 支持基于连接状态、短信和操作事件的手动评估。
- “立即投递”“重试”“重试全部”“清空”只在应用前台执行。
- 投递成功、失败和重试状态进入本地队列/日志表。
不使用 iOS 远程推送、后台任务调度或常驻网络轮询。
### 操作、任务与审计
- 移植操作目录、准备/执行两段式协议、确认令牌、版本检查和风险等级。
- 操作准备写入本地数据库;应用退出时把未完成任务标记为终止或未知结果。
- 保留任务列表、任务详情、条目结果、尝试记录、取消、重试和手动 reconcile。
- 审计事件记录操作者、请求、目标、结果、参数摘要和异常原因。
定时任务创建与自动触发移除。已有调度模型只保留为历史只读视图;新的“手动执行”入口不创建周期任务。
### 日志与系统
- 连接日志、运行日志和诊断汇总进入日志中心。
- 支持筛选、清空、修剪、导出和详情展开。
- 系统维护显示数据库、组件数量、可回收空间、应用版本和平台信息。
- 手动清理、`VACUUM`、校验、备份、恢复、导入、导出和删除备份。
- 自动备份、自动日志修剪和自动数据保留调度移除;保留策略仅作为手动清理的默认参数。
### 分组、标签与身份
- 设备分组和标签完整本地管理,并用于节点筛选、通知规则和批量操作。
- 设备身份记录用于识别异常换绑和重复身份,前台刷新时更新。
- 授权模式支持自动与手动;手动模式在检测到身份变化时要求确认。
## 定时能力的移除边界
以下行为不实现:
- 应用启动后的周期网络轮询。
- iOS 后台任务、静默推送唤醒、常驻 Socket 或后台同步。
- Cron 表达式解析与周期执行。
- 自动备份、自动队列投递、自动日志清理。
- 控制台自更新。
以下行为保留:
- 进入前台、下拉刷新、页面切换或用户点击时执行一次同步。
- 用户主动触发队列投递、备份、清理和 reconcile。
- 前台期间页面可见时的按需状态刷新。
## 上游访问与安全
- 上游请求只允许显式注册的方法和路径,不提供任意 URL 代理。
- 响应大小、超时、JSON 结构和敏感字段按现有上游网关边界限制。
- 实例密码存入 Keychain;会话 Cookie 只保存在内存,不写入 SQLite 或日志。
- 登录会话按实例隔离;修改实例地址后立即清空旧会话。
- 日志不输出 Cookie、Authorization、密码、确认令牌、完整短信正文或敏感通知配置。
- 短信和通知正文在列表中支持脱敏显示,详情页按现有显隐交互显示原文。
iOS 允许访问用户明确输入的 HTTP SimAdmin 实例地址。设置页必须显示“HTTP 明文传输”警告;HTTPS 和受信任内网地址不做额外降级提示。
## 数据模型与迁移
SQLite 保留以下核心表,字段可按 Swift 领域模型调整,但不得丢失现有用户语义:
- 实例:`instances`、`instance_tags`、`tag_registry`、`device_groups`。
- 状态:`status_snapshots`、`capabilities`、`connection_logs`、`device_identities`。
- 消息:`sms_messages`、`sms_outbox`。
- 通知:`notification_channels`、`notification_rules`、`notification_deliveries`、`notification_queue`。
- 操作:`jobs`、`job_items`、`job_attempts`、`operation_preparations`、`audit_events`。
- 设置:`app_settings`、维护保留策略。
首次启动创建 iOS 版本 1 迁移。不导入现有 Node 服务数据库作为第一版要求;后续可在设置页提供数据库导入兼容层。导入时必须拒绝无法识别的 schema 版本,并在写入前创建安全备份。
## 构建 IPA
新增 Swift Package `apps/ios` 和构建脚本 `scripts/build-ios-ipa.sh`。Swift Package 是 iOS 源码与依赖的单一事实来源,不引入 XcodeGen 或检入的 Xcode 工程文件。
1. 使用 `swift build --configuration release --triple arm64-apple-ios16.0` 和 iPhoneOS SDK 编译 `MultiSimAdmin` 可执行文件。
2. 组装 `Payload/MultiSimAdmin.app`,包含可执行文件、`Info.plist`、资源包和应用图标。
3. `Info.plist` 声明 iOS 16 最低版本、iPhone 方向、深浅色外观、ATS 例外和启动屏,但不声明后台模式。
4. 压缩为 `build/ipa/MultiSimAdmin.ipa`。
5. 输出 IPA 路径、大小和 SHA-256。
构建脚本不得要求证书或描述文件。如果目标环境需要伪签名,由用户使用 TrollStore 或自有工具处理;仓库脚本不默认修改二进制签名。
## 错误处理
- 网络失败显示实例名、可读原因和重试操作,不中断其他实例刷新。
- 单个实例失败只影响该实例卡片或详情,不使节点总览整页失败。
- 上游 401 显示“需要登录”,403 显示权限受限,404 显示不支持,超时明确区分网络不可达。
- 删除、恢复、重启、清空和高风险操作使用统一确认弹窗,显示目标数量与不可恢复提示。
- 数据库迁移失败时保持只读启动屏,提示备份文件位置,不自动删除旧数据。
## 测试与验收
XCTest 覆盖:
- 实例创建、地址校验、版本冲突、密码保存、登录会话隔离和删除补偿。
- 上游路径白名单、超时、错误状态、Cookie 注入和响应解析。
- 短信归档、去重、发送、删除、队列前台 flush 和失败重试。
- 通知规则评估、队列处理、通道投递和日志写入。
- 操作准备/执行、确认令牌、任务结果、审计和中断任务恢复。
- 数据库迁移、备份导出、恢复预览和敏感字段排除。
- UI 层验证核心页面可达、空态/错误态存在、按钮尺寸一致、脱敏状态正确。
构建验收:
- iOS 16 Simulator 可安装并启动。
- 390、768 和 1024 点宽度下无横向溢出、文字截断或控件重叠。
- `scripts/build-ios-ipa.sh` 在干净构建目录产出未签名 IPA,并输出 SHA-256。
- 应用不请求定位、相机、麦克风、通讯录或后台刷新权限。
## 非目标
- 不实现 App Store 发布、TestFlight、推送证书或开发者签名。
- 不在 iPhone 上运行 Node.js、Fastify 或现有 SQLite 服务端。
- 不实现后台常驻、Cron 自动化、自动备份或控制台自更新。
- 不支持多用户、远程 Web 登录或浏览器访问 iPhone 内的控制面。
- 不移除实例自身密码能力;只移除 multi-simadmin 的全局访问密码。
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IOS_ROOT="$ROOT/apps/ios"
BUILD_ROOT="$ROOT/build/ipa"
APP_NAME="MultiSimAdmin"
DERIVED_ROOT="${MULTI_SIMADMIN_IOS_DERIVED:-$IOS_ROOT/.build/ios-derived}"
cd "$IOS_ROOT"
xcodebuild -scheme "$APP_NAME" \
-destination 'generic/platform=iOS' \
-configuration Release \
-derivedDataPath "$DERIVED_ROOT" \
build
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \
"$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
cp "Sources/$APP_NAME/Resources/Info.plist" \
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
xcrun actool \
--compile "$BUILD_ROOT/Payload/$APP_NAME.app" \
--platform iphoneos \
--minimum-deployment-target 16.0 \
--app-icon AppIcon \
--output-format human-readable-text \
--output-partial-info-plist "$BUILD_ROOT/AssetCatalog-Info.plist" \
"$IOS_ROOT/Sources/$APP_NAME/Resources/Assets.xcassets"
if [[ ! -f "$BUILD_ROOT/Payload/$APP_NAME.app/Assets.car" ]]; then
echo 'Assets.car missing: application icon was not compiled' >&2
exit 1
fi
/usr/libexec/PlistBuddy -c "Merge '$BUILD_ROOT/AssetCatalog-Info.plist'" \
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
plutil -lint "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
cd "$BUILD_ROOT"
zip -qry "$APP_NAME.ipa" Payload
shasum -a 256 "$APP_NAME.ipa" > "$APP_NAME.ipa.sha256"
printf 'IPA: %s/%s.ipa\n' "$BUILD_ROOT" "$APP_NAME"
printf 'Size: %s bytes\n' "$(stat -f %z "$APP_NAME.ipa")"
printf 'SHA-256: '
cat "$APP_NAME.ipa.sha256"
+120
View File
@@ -0,0 +1,120 @@
#!/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)
}