Compare commits

...
37 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
chick 001659ed91 style(web): unify panel, group and state controls
Standardize panel actions, group filters, segmented modes, workbench tabs, message filters, identity controls and privacy toggles on the shared button scale. Add cascade-aware tests so stale compact sizes and low-contrast states cannot return.
2026-09-08 19:58:10 +08:00
chick bfe5025943 style(web): close control-system gaps found in the deep sweep 2026-09-08 08:02:01 +08:00
chick c0adbcbc8a style(web): unify accessible control system 2026-09-08 01:00:22 +08:00
chick 61a5aee48d fix(events): close review gaps found in the follow-up sweep
- deletion flow emitted job/instance envelopes before the transaction that
  closed the job rows; subscribers refetching on the notification saw
  status 'running' with no further event. Emits now happen after commit,
  including the failure path
- interrupted-deletion sweep now emits job envelopes with the job's real
  request_id (it previously stayed silent while the secure-execution sweep
  already emitted)
- verified during the sweep: backup restore validation re-derives the
  expected schema via migrateDatabase, so migration 18 is automatically
  covered; no tables reference audit_events or operation_preparations, so
  the retention deletes cannot trip FK constraints
2026-09-07 05:57:35 +08:00
chick 70e598208a perf,feat(events),ops: third optimization pass
Overview cold start:
- InstanceResourceService persists every live fetch into status_snapshots
  (category 'resources') and serves a snapshot younger than 2 minutes
  before calling upstream, so the first fleet overview after a restart
  costs zero device requests; instance deletion cleans up via FK cascade

Bounded retention for the four fastest-growing tables (15-minute sweep):
- connection_logs: one row per probe per beat had no automatic cleanup
- audit_events: new pruneBefore (90d)
- operation_preparations: every prepare/retry attempt inserted a row,
  terminal rows now expire after 7 days
- notification_queue: terminal rows pruned after 30 days

SSE events now cover instance lifecycle:
- create/update emit instance envelopes from the routes; the two-phase
  delete emits a job envelope on every terminal transition plus an
  instance envelope when the node actually disappears, so fleet and
  instance views invalidate in real time

Linux ops:
- 'install.sh unit' writes systemd user units for API and gateway with
  Restart=on-failure, 0600 secret injection, and
  MULTI_SIMADMIN_SYSTEMD_UNIT wired so console self-update restarts via
  systemctl -- closing the self-update loop on Linux
2026-09-07 02:43:11 +08:00
chick c1be714ba4 perf,feat(events),hardening: second review pass
Fleet overview N+1:
- InstanceResourceService gains a 30s TTL cache with single-flight
  coalescing; the overview no longer fires six live upstream requests per
  device on every render (plus the re-login storm), while the per-device
  detail route probes live via force:true

Event journal becomes live:
- job terminal transitions (manual executions and the interrupted sweep)
  now append to the journal, so /api/v1/events SSE feeds the frontend's
  invalidation controller that was built but never received events
- journal pruning moves off the append hot path (was an unindexable
  full-table json_extract scan per insert) onto the retention timer

Console auth hardening:
- scrypt upgraded from N=16384 to N=2^16 (OWASP interactive guidance);
  a new password_kdf column records the derivation per row and legacy
  hashes rehash transparently on the next successful login without
  invalidating sessions (migration 18)

Legacy stack:
- instance URL validation blocks IPv4-compatible IPv6 after WHATWG
  canonicalization (::a9fe:a9fe metadata, ::7f00:1 loopback slipped past)
- status polls cool down auto-login for 60s after a failed attempt so a
  stale saved password cannot hammer the device into an account lockout

Build hygiene:
- web bundle splits app (410kB) from vendor (212kB) so framework code
  stays cacheable across releases; stale root package-lock.json removed
  (pnpm is the only lockfile)
2026-09-07 02:20:41 +08:00
122 changed files with 18572 additions and 4230 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
+38 -2
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 地址。
@@ -45,6 +69,16 @@ sh /tmp/multi-simadmin-install.sh uninstall
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置 macOS 为 `~/Library/Application Support/multi-simadmin`,Linux 为 `~/.local/share/multi-simadmin`(遵循 XDG),均可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
#### Linux systemd(可选,但推荐)
`sh install.sh unit` 会生成 `~/.config/systemd/user/` 下的 API 与 Gateway 用户单元(`Restart=on-failure`、开机自启、密钥经 0600 的 `service-env` 注入)。随后:
```bash
systemctl --user enable --now multi-simadmin-api.service multi-simadmin-gateway.service
```
单元内声明了 `MULTI_SIMADMIN_SYSTEMD_UNIT`,因此 Web 控制台的“在线更新”在 Linux 上会把重启交给 systemd 完成,形成自更新闭环。由 systemd 托管后请用 `systemctl` 管理进程,不要再用本脚本 `start/stop`,避免双进程争抢端口。
### Fleet 节点总览与跨域健康
`/fleet` 读取本机 Control Plane 中的节点状态、资源快照与设备号码,形成节点总览。侧栏在实例总数 / 在线 / 需处理之外,还聚合跨域健康指标:
@@ -80,10 +114,12 @@ sh /tmp/multi-simadmin-install.sh uninstall
```bash
cp config.example.json config.json
npm install
npm start
corepack pnpm install
corepack pnpm start
```
> 仓库统一使用 pnpm(`pnpm-lock.yaml`)作为唯一 lockfile;陈旧的 `package-lock.json` 已移除。
## 配置
编辑 `config.json`:
@@ -139,6 +139,17 @@ function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem
export class AuditQueryService {
constructor(private readonly db: Database.Database) {}
/** Retention hook: drops audit records older than the cutoff. */
pruneBefore(cutoffIso: string): number {
if (typeof cutoffIso !== 'string' || !Number.isFinite(Date.parse(cutoffIso)))
throw new RangeError('cutoffIso must be a valid date-time');
return Number(
this.db
.prepare('DELETE FROM audit_events WHERE created_at < ?')
.run(new Date(cutoffIso).toISOString()).changes,
);
}
summary(limit = 5): ControlPlaneAuditSummary {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) validation('limit is invalid');
const totalRow = this.db.prepare('SELECT COUNT(*) AS total FROM audit_events').get() as
@@ -6,6 +6,25 @@ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 1024;
const SCRYPT_KEY_LENGTH = 32;
// Legacy derivations used N=16384; OWASP guidance for interactive login sits at
// N=2^16/r=8. Rows keep their derivation tag so verification stays correct and
// the upgrade happens transparently on the next successful login.
interface ScryptParams {
readonly N: number;
readonly r: number;
readonly p: number;
}
const SCRYPT_LEGACY: ScryptParams = { N: 16_384, r: 8, p: 1 };
const SCRYPT_CURRENT: ScryptParams = { N: 65_536, r: 8, p: 1 };
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
const scryptTag = (params: ScryptParams): string => `scrypt:${params.N}:${params.r}:${params.p}`;
const parseScryptTag = (value: string | null | undefined): ScryptParams => {
if (!value) return SCRYPT_LEGACY;
const match = /^scrypt:(\d+):(\d+):(\d+)$/.exec(value);
if (!match) return SCRYPT_LEGACY;
const [N, r, p] = match.slice(1).map(Number) as [number, number, number];
return { N, r, p };
};
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
const MIN_IDLE_TIMEOUT_MINUTES = 5;
@@ -16,6 +35,7 @@ interface AuthConfigRow {
readonly password_salt: string | null;
readonly password_hash: string | null;
readonly password_revision: number;
readonly password_kdf: string | null;
}
export interface ConsoleAuthStatus {
@@ -87,10 +107,23 @@ export class ConsoleAuthService {
!config.password_hash
)
throw new ConsoleAuthError('INVALID_CREDENTIALS');
const actual = await this.derive(password, config.password_salt);
const params = parseScryptTag(config.password_kdf);
const actual = await this.derive(password, config.password_salt, params);
const expected = Buffer.from(config.password_hash, 'hex');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
throw new ConsoleAuthError('INVALID_CREDENTIALS');
if (params !== SCRYPT_CURRENT) {
// Transparent upgrade: same salt, stronger derivation, same revision so
// live sessions survive.
const hash = (await this.derive(password, config.password_salt, SCRYPT_CURRENT)).toString(
'hex',
);
this.db
.prepare(
'UPDATE console_auth_config SET password_hash=?,password_kdf=?,updated_at=? WHERE singleton=1',
)
.run(hash, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
}
return this.createSession(config.password_revision);
}
@@ -121,14 +154,14 @@ export class ConsoleAuthService {
this.db
.prepare(
`INSERT INTO console_auth_config
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
VALUES (1,1,?,?,?,?)
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
VALUES (1,1,?,?,?,?,?)
ON CONFLICT(singleton) DO UPDATE SET
protection_enabled=1,password_salt=excluded.password_salt,
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
updated_at=excluded.updated_at`,
password_kdf=excluded.password_kdf,updated_at=excluded.updated_at`,
)
.run(salt, hash, revision, this.now().toISOString());
.run(salt, hash, revision, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
this.db.prepare('DELETE FROM console_auth_sessions').run();
return this.createSession(revision);
})();
@@ -178,7 +211,7 @@ export class ConsoleAuthService {
private config(): AuthConfigRow | undefined {
return this.db
.prepare(
`SELECT protection_enabled,password_salt,password_hash,password_revision
`SELECT protection_enabled,password_salt,password_hash,password_revision,password_kdf
FROM console_auth_config WHERE singleton=1`,
)
.get() as AuthConfigRow | undefined;
@@ -221,13 +254,17 @@ export class ConsoleAuthService {
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
}
private derive(password: string, salt: string): Promise<Buffer> {
private derive(
password: string,
salt: string,
params: ScryptParams = SCRYPT_CURRENT,
): Promise<Buffer> {
return new Promise((resolvePromise, reject) => {
deriveScrypt(
password,
Buffer.from(salt, 'hex'),
SCRYPT_KEY_LENGTH,
{ N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
{ ...params, maxmem: SCRYPT_MAXMEM },
(error, derivedKey) => {
if (error) reject(error);
else resolvePromise(derivedKey);
@@ -185,7 +185,6 @@ export class EventJournal {
const sequence = Number(result.lastInsertRowid);
if (!Number.isSafeInteger(sequence))
throw new Error('event journal sequence is not a safe integer');
this.#prune();
const appended = { sequence, envelope };
for (const subscriber of [...this.#subscribers]) subscriber(appended);
return appended;
@@ -236,7 +235,11 @@ export class EventJournal {
};
}
#prune(): void {
/**
* Maintenance hook for a periodic timer. Pruning per append turned every
* insert into an unindexable full-table json_extract scan.
*/
prune(): void {
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
this.#database
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
@@ -1248,6 +1248,20 @@ export class CentralNotificationService {
if (result.changes !== 1) throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
}
/** Retention for terminal queue rows; pending/sending backlog is never touched. */
pruneFinishedQueue(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(Date.parse(this.#now()) - olderThanMs).toISOString();
return Number(
this.#db
.prepare(
"DELETE FROM notification_queue WHERE status IN ('succeeded','failed','cancelled') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
deleteQueueItem(id: string): void {
if (this.#db.prepare('DELETE FROM notification_queue WHERE id=?').run(id).changes !== 1)
throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
@@ -1,4 +1,5 @@
import type Database from 'better-sqlite3';
import type { EventEnvelope } from '../events/event-journal.js';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { Job, Preparation, PrepareOperationRequest } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
@@ -75,6 +76,8 @@ interface Options {
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
/** Receives job/instance envelopes so the SSE journal can invalidate fleet views. */
readonly emit?: (envelope: EventEnvelope) => void;
}
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
@@ -93,6 +96,7 @@ export class DeleteInstanceOperation {
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
private readonly emit: Options['emit'];
constructor(options: Options) {
this.db = options.db;
@@ -101,6 +105,7 @@ export class DeleteInstanceOperation {
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
this.emit = options.emit;
}
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
@@ -318,6 +323,7 @@ export class DeleteInstanceOperation {
const release = await this.releaseBindingPort(input.instanceId, input.requestId);
if (release?.status === 'failed') {
this.closeAsFailed(ids, input.requestId, BINDING_REFUSAL_CODE);
this.#emitJob(ids.job, input.requestId);
return this.job(ids.job);
}
try {
@@ -342,6 +348,16 @@ export class DeleteInstanceOperation {
)
.run(finished, finished, ids.job);
})();
// Events only after the terminal rows are committed, so a subscriber that
// refetches on the notification sees the finished job, not 'running'.
this.#emit({
kind: 'instance',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId: input.requestId,
instanceId: input.instanceId,
});
this.#emitJob(ids.job, input.requestId);
} catch (error) {
const finished = this.clock().toISOString();
const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result';
@@ -361,6 +377,7 @@ export class DeleteInstanceOperation {
.prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
.run(state, finished, finished, ids.job);
})();
this.#emitJob(ids.job, input.requestId);
}
return this.job(ids.job);
}
@@ -379,6 +396,25 @@ export class DeleteInstanceOperation {
}
/** Closes the job without touching the instance row, so the device stays listed and retryable. */
#emit(envelope: EventEnvelope): void {
if (!this.emit) return;
try {
this.emit(envelope);
} catch {
// A journal failure must never fail the operation itself.
}
}
#emitJob(jobId: string, requestId: string): void {
this.#emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
}
private closeAsFailed(
ids: { job: string; item: string; attempt: string },
requestId: string,
@@ -416,9 +452,10 @@ export class DeleteInstanceOperation {
const finished = this.clock().toISOString();
return this.db.transaction(() => {
const jobs = this.db
.prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string }>;
for (const { id } of jobs) {
.prepare("SELECT id, request_id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string; request_id: string }>;
for (const { id, request_id: requestId } of jobs) {
this.#emitJob(id, requestId);
this.db
.prepare(
"UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { EventEnvelope } from '../events/event-journal.js';
import type {
ExecuteOperationRequest,
Job,
@@ -121,6 +122,8 @@ interface Options {
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
readonly nonceFactory?: () => string;
/** Receives job terminal transitions so the SSE journal has a live producer. */
readonly emit?: (envelope: EventEnvelope) => void;
}
interface PreparationRow {
operation_id: string;
@@ -442,9 +445,28 @@ export class SecureOperationExecution {
}
}
this.finish(ids, state, code);
this.#emitTerminal(ids.job, state, requestId);
return this.job(ids.job);
}
/**
* Retention for consumed/expired/invalidated preparations. Every prepare
* (manual or a scheduled retry attempt) inserts a row, so without this the
* table grows one row per attempt forever.
*/
pruneTerminalPreparations(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(this.clock().getTime() - olderThanMs).toISOString();
return Number(
this.options.db
.prepare(
"DELETE FROM operation_preparations WHERE status IN ('consumed','expired','invalidated') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
reconcileInterruptedJobs(): number {
const now = this.clock().toISOString();
return this.options.db.transaction(() => {
@@ -453,7 +475,10 @@ export class SecureOperationExecution {
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
)
.all() as Array<{ id: string }>;
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
for (const row of jobs) {
this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
this.#emitTerminal(row.id, 'unknown-result', now);
}
return jobs.length;
})();
}
@@ -479,6 +504,21 @@ export class SecureOperationExecution {
.run(state, now, now, ids.job);
})();
}
#emitTerminal(jobId: string, state: string, requestId: string): void {
if (!this.options.emit) return;
try {
this.options.emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
} catch {
// A journal failure must never fail the operation itself.
}
}
private finishByJob(jobId: string, now: string, state: string, code: string): void {
this.options.db
.prepare(
@@ -1,4 +1,6 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
InstanceResourceService,
@@ -155,6 +157,112 @@ describe('instance resource allowlist parsing', () => {
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
});
describe('persisted resource snapshots', () => {
it('serves the first overview after a restart from status_snapshots with zero upstream calls', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
// status_snapshots carries an FK to instances; seed the node it belongs to.
db.prepare(
"INSERT INTO instances (id,name,base_url,created_at,updated_at) VALUES ('device-1','device-1','http://192.168.3.55:3000','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')",
).run();
let clock = 1_000;
let livePasses = 0;
const build = () => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
db,
now: () => clock,
} as never);
return { service, requests };
};
const first = build();
await first.service.get('device-1');
livePasses = first.requests.length;
expect(livePasses).toBe(6);
// A brand-new service (process restart) reads the persisted snapshot.
const second = build();
const resources = await second.service.get('device-1');
expect(second.requests).toHaveLength(0);
expect(resources).toEqual({ controlOnline: true });
// Past the freshness window the probe goes live again and repersists.
clock += 121_000;
await second.service.get('device-1');
expect(second.requests.length).toBe(livePasses);
db.close();
});
});
describe('overview cache', () => {
const minimal = (cacheTtlMs?: number) => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
} as never);
return { service, requests };
};
it('serves repeat overview reads from the TTL cache and refreshes after expiry', async () => {
let clock = 1_000;
const { service, requests } = minimal();
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
await service.get('device-1');
const firstPass = requests.length;
await service.get('device-1');
expect(requests.length).toBe(firstPass);
clock += 31_000;
await service.get('device-1');
expect(requests.length).toBeGreaterThan(firstPass);
});
it('coalesces concurrent overview reads into one upstream pass', async () => {
const { service, requests } = minimal();
await Promise.all([
service.get('device-1'),
service.get('device-1'),
service.get('device-1'),
]);
expect(requests).toHaveLength(6);
});
it('probes live again when a detail view forces a refresh', async () => {
const clock = 5_000;
const { service, requests } = minimal();
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
await service.get('device-1');
const afterOverview = requests.length;
await service.get('device-1', { force: true });
expect(requests.length).toBe(afterOverview + 6);
});
it('keeps reads uncached when the TTL is disabled', async () => {
const { service, requests } = minimal(0);
await service.get('device-1');
await service.get('device-1');
expect(requests).toHaveLength(12);
});
});
it('probes passwordless instances without manufacturing a cookie', async () => {
const requests: Array<{ url: string; headers: Readonly<Record<string, string>> }> = [];
const service = new InstanceResourceService({
@@ -1,4 +1,6 @@
import { lookupOperator } from '@multi-simadmin/contracts';
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { InstanceService } from '../instances/instance-service.js';
import type {
@@ -163,7 +165,17 @@ export function parseSignalStrength(response: UpstreamResponse): InstanceResourc
return signalPercent === undefined ? {} : { signalPercent };
}
const DEFAULT_CACHE_TTL_MS = 30_000;
const DEFAULT_SNAPSHOT_FRESH_MS = 120_000;
const RESOURCE_SNAPSHOT_CATEGORY = 'resources';
export class InstanceResourceService {
readonly #cache = new Map<
string,
{ readonly resources: InstanceResources; readonly fetchedAt: number }
>();
readonly #inflight = new Map<string, Promise<InstanceResources>>();
constructor(
private readonly options: {
readonly instances: InstanceService;
@@ -174,10 +186,115 @@ export class InstanceResourceService {
origin: string,
force?: boolean,
) => Promise<void>;
/** Fleet overview fan-out reads through this cache; 0 disables it. */
readonly cacheTtlMs?: number;
/**
* When set, every live fetch is persisted into status_snapshots
* (category 'resources') and a snapshot younger than snapshotFreshMs is
* served before any upstream call, so the first overview after a restart
* costs zero device requests.
*/
readonly db?: Database.Database;
readonly snapshotFreshMs?: number;
readonly now?: () => number;
},
) {}
async get(instanceId: string): Promise<InstanceResources> {
/**
* Fleet overview fans out one read per registered node; without a shared TTL
* that is six live upstream requests per device on every page render (plus a
* re-login storm when sessions expired). Detail views pass `force` to probe
* the selected device immediately.
*/
async get(
instanceId: string,
{ force = false }: { readonly force?: boolean } = {},
): Promise<InstanceResources> {
const ttl = this.options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
const now = this.options.now?.() ?? Date.now();
if (ttl > 0 && !force) {
const cached = this.#cache.get(instanceId);
if (cached && now - cached.fetchedAt < ttl) return cached.resources;
const persisted = this.#readSnapshot(instanceId, now);
if (persisted) {
this.#cache.set(instanceId, { resources: persisted, fetchedAt: now });
return persisted;
}
const inflight = this.#inflight.get(instanceId);
if (inflight) return inflight;
const pending = this.#fetch(instanceId)
.then((resources) => {
const fetchedAt = this.options.now?.() ?? now;
this.#cache.set(instanceId, { resources, fetchedAt });
this.#writeSnapshot(instanceId, resources, fetchedAt);
return resources;
})
.finally(() => {
this.#inflight.delete(instanceId);
});
this.#inflight.set(instanceId, pending);
return pending;
}
const resources = await this.#fetch(instanceId);
if (ttl > 0) {
this.#cache.set(instanceId, { resources, fetchedAt: now });
this.#writeSnapshot(instanceId, resources, now);
}
return resources;
}
#readSnapshot(instanceId: string, now: number): InstanceResources | undefined {
const db = this.options.db;
if (!db) return undefined;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
try {
const row = db
.prepare(
'SELECT payload_json, observed_at FROM status_snapshots WHERE instance_id=? AND category=?',
)
.get(instanceId, RESOURCE_SNAPSHOT_CATEGORY) as
| { payload_json: string; observed_at: string }
| undefined;
if (!row) return undefined;
const age = now - Date.parse(row.observed_at);
if (!Number.isFinite(age) || age < 0 || age >= freshMs) return undefined;
const payload: unknown = JSON.parse(row.payload_json);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
return undefined;
return payload as InstanceResources;
} catch {
return undefined;
}
}
#writeSnapshot(instanceId: string, resources: InstanceResources, observedAt: number): void {
const db = this.options.db;
if (!db) return;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
const observed = new Date(observedAt).toISOString();
const expiresAt = new Date(observedAt + freshMs).toISOString();
try {
db.prepare(
`INSERT INTO status_snapshots (id, instance_id, category, state, payload_json, observed_at, expires_at, created_at)
VALUES (?, ?, ?, 'fresh', ?, ?, ?, ?)
ON CONFLICT(instance_id, category) DO UPDATE SET
id=excluded.id, state=excluded.state, payload_json=excluded.payload_json,
observed_at=excluded.observed_at, expires_at=excluded.expires_at, created_at=excluded.created_at`,
).run(
randomUUID(),
instanceId,
RESOURCE_SNAPSHOT_CATEGORY,
JSON.stringify(resources),
observed,
expiresAt,
observed,
);
} catch {
// Persistence is an optimization; a failed write must not fail the read.
}
}
async #fetch(instanceId: string): Promise<InstanceResources> {
const [instance, session] = await Promise.all([
this.options.instances.get(instanceId),
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
+38 -2
View File
@@ -167,6 +167,12 @@ function createHubDeviceReader(
};
}
// Retention windows for operator-facing history. Preparations are short-lived
// protocol state and cycle faster; audit records are the longest-lived.
const RETENTION_HISTORY_MS = 30 * 24 * 60 * 60 * 1000;
const RETENTION_AUDIT_MS = 90 * 24 * 60 * 60 * 1000;
const RETENTION_PREPARATION_MS = 7 * 24 * 60 * 60 * 1000;
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
@@ -245,6 +251,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
sessions,
request: options.upstream.request,
ensureSession,
db: options.db,
});
const messages = new InstanceMessageService({
instances,
@@ -347,8 +354,14 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
instances,
releaseBinding,
now: options.now,
emit: (envelope) => eventJournal.append(envelope),
})
: new DeleteInstanceOperation({ db: options.db, instances, releaseBinding });
: new DeleteInstanceOperation({
db: options.db,
instances,
releaseBinding,
emit: (envelope) => eventJournal.append(envelope),
});
deletion.reconcileInterruptedJobs();
const listHubDevices = createHubDeviceReader(options.db, instances);
const resolveOperationCookie = async (
@@ -381,6 +394,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
const secureExecution = new SecureOperationExecution({
db: options.db,
registry: secureOperationRegistry,
emit: (envelope) => eventJournal.append(envelope),
transport: {
request: async ({ origin, path, body, contentType, instanceId }) => {
const cookie = await resolveOperationCookie(instanceId, origin);
@@ -510,6 +524,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
resources,
messages,
registerDeletionPreparationRoute: false,
onEvent: (envelope) => eventJournal.append(envelope),
});
registerFleetRoutes(app, {
instances,
@@ -584,7 +599,28 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
.catch(() => undefined);
// Delivered and abandoned outbox rows otherwise accumulate forever.
void Promise.resolve()
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
.then(() => smsOutbox.prune(RETENTION_HISTORY_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => eventJournal.prune())
.catch(() => undefined);
// Connection probes write a log row per beat per node; without a sweep
// that alone is thousands of rows a day.
void Promise.resolve()
.then(() =>
connectionLogs.prune({
before: new Date(Date.now() - RETENTION_HISTORY_MS).toISOString(),
}),
)
.catch(() => undefined);
void Promise.resolve()
.then(() => audit.pruneBefore(new Date(Date.now() - RETENTION_AUDIT_MS).toISOString()))
.catch(() => undefined);
void Promise.resolve()
.then(() => secureExecution.pruneTerminalPreparations(RETENTION_PREPARATION_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => centralNotifications.pruneFinishedQueue(RETENTION_HISTORY_MS))
.catch(() => undefined);
}, logPruneIntervalMs)
: undefined;
@@ -670,6 +670,16 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_device_identities_imei ON device_identities(imei, instance_id)',
],
},
{
id: 18,
name: 'console-auth-scrypt-params',
statements: [
// Legacy hashes were derived with N=16384; new derivations use stronger
// parameters. The column records the derivation per row; NULL means legacy
// and is transparently upgraded after the next successful login.
'ALTER TABLE console_auth_config ADD COLUMN password_kdf TEXT',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -49,6 +49,49 @@ afterEach(async () => {
for (const db of dbs.splice(0)) if (db.open) db.close();
});
async function seedLegacyHash(db: Database.Database, password: string): Promise<void> {
const { scrypt } = await import('node:crypto');
const salt = 'a'.repeat(32);
const hash = await new Promise<Buffer>((resolve, reject) =>
scrypt(password, Buffer.from(salt, 'hex'), 32, { N: 16_384, r: 8, p: 1 }, (error, key) =>
error ? reject(error) : resolve(key),
),
);
db.prepare(
`INSERT INTO console_auth_config
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
VALUES (1,1,?,?,1,NULL,?)`,
).run(salt, hash.toString('hex'), new Date().toISOString());
}
describe('console password derivation upgrade', () => {
it('verifies a legacy N=16384 hash and transparently rehashes with current params', async () => {
const { app, db, auth } = fixture();
await seedLegacyHash(db, PASSWORD);
const before = db
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
.get() as { password_hash: string; password_kdf: string | null };
expect(before.password_kdf).toBeNull();
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { password: PASSWORD },
});
expect(login.statusCode).toBe(204);
const after = db
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
.get() as { password_hash: string; password_kdf: string | null };
expect(after.password_kdf).toBe('scrypt:65536:8:1');
expect(after.password_hash).not.toBe(before.password_hash);
// The upgraded hash still verifies with a plain login through the service.
await expect(auth.login(PASSWORD)).resolves.toBeTruthy();
});
});
describe('aggregate-console password protection HTTP boundary', () => {
it('starts migration-safe as disabled and never returns password material', async () => {
const { app } = fixture();
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import { buildApp } from '../../app.js';
import {
InstanceSessionStore,
@@ -78,13 +79,36 @@ const fixture = () => {
idFactory: () => `operation-${++id}`,
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
});
const events: Array<Record<string, unknown>> = [];
const onEvent = (envelope: EventEnvelope): void => {
events.push(envelope as unknown as Record<string, unknown>);
};
const app = buildApp({
registerRoutes: (scope) =>
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
registerInstanceRoutes(scope, { instances, connections, login, deletion, onEvent }),
});
return { app, instances };
return { app, instances, events };
};
describe('instance route SSE events', () => {
it('emits an instance envelope when a node is created', async () => {
const { app, events } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
password: { action: 'set', password: 'never-return-this' },
},
});
expect(created.statusCode).toBe(201);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ kind: 'instance', requestId: expect.any(String) });
expect(String(events[0]?.instanceId)).toBeTruthy();
});
});
describe('instance HTTP routes', () => {
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
const { app } = fixture();
@@ -202,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({
+29 -1
View File
@@ -20,6 +20,8 @@ import {
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import { randomUUID } from 'node:crypto';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import {
MessageServiceError,
type InstanceMessageService,
@@ -33,6 +35,8 @@ export interface InstanceRoutesOptions {
readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean;
/** Receives instance lifecycle envelopes so the SSE journal can invalidate fleet views. */
readonly onEvent?: (envelope: EventEnvelope) => void;
}
const problem = (
request: FastifyRequest,
@@ -56,6 +60,25 @@ const problem = (
detail,
requestId: request.id,
});
const emitInstanceEvent = (
options: { readonly onEvent?: (envelope: EventEnvelope) => void },
instanceId: string,
requestId: string,
): void => {
if (!options.onEvent) return;
try {
options.onEvent({
kind: 'instance',
id: randomUUID(),
occurredAt: new Date().toISOString(),
requestId,
instanceId,
});
} catch {
// A journal failure must never fail the mutation itself.
}
};
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
const status = instanceErrorStatus[error.code];
return problem(
@@ -344,6 +367,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
async (request, reply) => {
try {
const created = await options.instances.create(bodyInput(request.body));
emitInstanceEvent(options, created.id, request.id);
return reply.header('ETag', etag(created)).code(201).send(created);
} catch (error) {
if (error instanceof InstanceServiceError)
@@ -370,7 +394,10 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
app.get(
'/api/v1/instances/:instanceId/resources',
wrap(async (request) =>
options.resources!.get((request.params as { instanceId: string }).instanceId),
// A single selected device is the one place a live probe is expected.
options.resources!.get((request.params as { instanceId: string }).instanceId, {
force: true,
}),
),
);
if (options.messages) {
@@ -431,6 +458,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
revision(request),
bodyPatch(request.body),
);
emitInstanceEvent(options, (request.params as { instanceId: string }).instanceId, request.id);
reply.header('ETag', etag(result));
return result;
}),
+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")
}
}
+1 -1
View File
@@ -288,7 +288,7 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
<h1 id="audit-title">审计</h1>
<p>只读运维审计事件。</p>
</div>
<Button htmlType="button" size="small" onClick={() => setAttempt((value) => value + 1)}>
<Button htmlType="button" onClick={() => setAttempt((value) => value + 1)}>
刷新审计事件
</Button>
</div>
@@ -59,6 +59,7 @@ describe('FleetIdentityPanel', () => {
render(<FleetIdentityPanel dataSource={source()} onClose={() => {}} onChanged={() => {}} />);
const dialog = await screen.findByRole('dialog', { name: '设备身份核对' });
await screen.findByText('已记录 2 台 · 待确认 1 台');
expect(dialog.textContent).toContain('已记录 2 台 · 待确认 1 台');
expect(screen.getByText('机房 A')).toBeTruthy();
expect(screen.getByText('同一 IMEI 出现在多台设备记录上')).toBeTruthy();
@@ -40,6 +40,7 @@ describe('FleetOrganizationPanel', () => {
);
expect(await screen.findByRole('dialog', { name: '分组与标签' })).toBeTruthy();
await screen.findByRole('button', { name: '编辑分组 核心' });
const core = screen.getByRole('button', { name: '编辑分组 核心' });
const row = core.closest('li');
expect(row?.textContent).toContain('2 台设备');
+11
View File
@@ -420,6 +420,17 @@ describe('FleetPage search and filter toolbar', () => {
});
describe('FleetPage batch and restart actions', () => {
it('keeps top-level create and batch actions on the same control scale', () => {
render(<FleetPage initialData={snapshot} />);
const create = screen.getByRole('link', { name: '添加实例' });
const batch = screen.getByRole('button', { name: '批量选择' });
expect(create.classList.contains('primary-action')).toBe(true);
expect(batch.classList.contains('animal-btn-small')).toBe(false);
expect(batch.className).toMatch(/animal-btn-middle-[A-Za-z0-9]+/);
});
it('keeps selection controls hidden until batch-selection mode is entered', () => {
render(<FleetPage initialData={snapshot} />);
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
-1
View File
@@ -984,7 +984,6 @@ export function FleetPage({
) : null}
<Button
htmlType="button"
size="small"
onClick={() => {
if (selectionMode) setSelectedIds(new Set());
setSelectionMode((value) => !value);
@@ -148,7 +148,8 @@ describe('Phase 6.3 Device Network read module', () => {
within(screen.getByRole('region', { name: '接口与地址' })).queryByRole('button'),
).toBeNull();
// Actions come from the pinned catalog; without a reachable API the card stays honest.
const actions = await screen.findByRole('region', { name: '设备操作' });
await screen.findByText('无法读取该模块的可用操作,请稍后重试。');
const actions = screen.getByRole('region', { name: '设备操作' });
expect(actions.textContent).toContain('无法读取该模块的可用操作');
});
@@ -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);

Some files were not shown because too many files have changed in this diff Show More