Compare commits
2
Commits
001659ed91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81cef1ad6c | ||
|
|
1caf6985ff |
@@ -226,6 +226,62 @@ describe('instance HTTP routes', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts the bodyless confirmation DELETE the console sends, and rejects a JSON claim', async () => {
|
||||
// Regression: the console used to send Content-Type: application/json on this bodyless
|
||||
// request. Fastify then rejected the empty body with 400 HTTP_400 before the route ran, so
|
||||
// "delete node" silently did nothing. Deletion carries its intent in headers, so a request
|
||||
// that merely *claims* a JSON body must fail loudly while the real shape must reach the route.
|
||||
const { app } = fixture();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
|
||||
});
|
||||
const id = (created.json() as { id: string }).id;
|
||||
|
||||
const preparation = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/prepare',
|
||||
payload: {
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
});
|
||||
const prepared = preparation.json() as { id: string; confirmationToken: string };
|
||||
const confirmationHeaders = {
|
||||
'if-match': '"rev-1"',
|
||||
'x-preparation-id': prepared.id,
|
||||
'x-confirmation-token': prepared.confirmationToken,
|
||||
};
|
||||
|
||||
// The old broken shape: an empty body that still declares itself as JSON.
|
||||
const claimedJsonBody = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/instances/${id}`,
|
||||
headers: { ...confirmationHeaders, 'content-type': 'application/json' },
|
||||
});
|
||||
expect(claimedJsonBody.statusCode).toBe(400);
|
||||
expect(claimedJsonBody.json()).toMatchObject({ code: 'HTTP_400' });
|
||||
// The node must survive the rejected attempt.
|
||||
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
|
||||
// The real console shape: headers only, no Content-Type, no body.
|
||||
const accepted = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/instances/${id}`,
|
||||
headers: confirmationHeaders,
|
||||
});
|
||||
expect(accepted.statusCode).toBe(202);
|
||||
expect(accepted.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
|
||||
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
|
||||
404,
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
|
||||
const { app } = fixture();
|
||||
const invalid = await app.inject({
|
||||
|
||||
@@ -109,12 +109,14 @@ describe('instance API data source', () => {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'If-Match': '"rev-3"',
|
||||
'X-Preparation-Id': 'prep-1',
|
||||
'X-Confirmation-Token': token,
|
||||
},
|
||||
});
|
||||
// A bodyless request must not claim a JSON body: the server's parser rejects the empty body
|
||||
// with 400 before the confirmation headers are ever read, so deletion never happens.
|
||||
expect(fetch.mock.calls[2]?.[1]?.headers).not.toHaveProperty('Content-Type');
|
||||
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
|
||||
// The confirmation is a bearer secret: it belongs in a header, never in a URL or a body.
|
||||
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
|
||||
|
||||
@@ -89,9 +89,17 @@ interface JsonResponse {
|
||||
readonly response: Response;
|
||||
}
|
||||
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
|
||||
// Content-Type describes the body. Sending it on a bodyless request makes the server's JSON
|
||||
// parser reject the empty body with 400 before the route runs — which silently killed every
|
||||
// DELETE (deletion) and POST (test-connection) that carries its intent in headers alone.
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers },
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw await safeError(response);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# iOS 原生 IPA 改写设计
|
||||
|
||||
日期:2026-09-08
|
||||
|
||||
## 目标
|
||||
|
||||
把 multi-simadmin 的管理能力改写为独立 iPhone 应用,而不是为现有 Web 服务做一个远程客户端或 WebView 壳。应用面向 iOS 16+ 和 TrollStore 安装环境,构建流程产出未签名 IPA;Apple 证书、开发者账号、TestFlight 和上架签名不在本项目范围内。
|
||||
|
||||
原生应用直接连接用户配置的各 SimAdmin 实例,本地承担实例注册、连接状态、消息归档、通知队列、任务历史、审计、日志和维护能力。不要求用户先运行 8788 控制台服务。现有 Web 前端与 Node API 保持不变,直到 iOS 版本验证后再另行决定发布关系。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
- 形态:SwiftUI 原生应用。
|
||||
- 最低系统:iOS 16.0。
|
||||
- 构建产物:`MultiSimAdmin.ipa`,默认不签名,后续由用户用 TrollStore 或自有证书处理。
|
||||
- 功能范围:优先保持现有控制台与 Hub 能力,但移除不适用于 iPhone 前台的定时/常驻能力。
|
||||
- 全局访问密码:移除;实例自身的登录凭据仍保留并安全存储。
|
||||
- 现有 UI 风格:移植为 iOS 原生组件,不照搬浏览器布局。
|
||||
|
||||
## 架构
|
||||
|
||||
新增 `apps/ios`,包含独立 Swift 应用。结构分为五层:
|
||||
|
||||
1. **App 层**:SwiftUI 应用入口、导航、生命周期和全局错误提示。
|
||||
2. **Presentation 层**:节点、消息、通知、日志、操作、设置等 SwiftUI 视图与 view model。
|
||||
3. **Domain 层**:实例、连接、消息、通知、操作、审计、维护等领域服务和值类型。
|
||||
4. **Upstream 层**:`URLSession` 访问 SimAdmin 实例,管理实例会话 Cookie、超时、响应解析和安全路径白名单。
|
||||
5. **Persistence 层**:SQLite 持久化本地控制面数据;实例密码等敏感值使用 iOS Keychain。
|
||||
|
||||
数据库采用 GRDB 管理迁移与查询,避免手写 SQLite C API。凭据使用 Keychain 的应用私有可迁移项,不进入数据库明文、日志、备份预览或导出文件。
|
||||
|
||||
应用不内嵌 Node.js。iOS 版本的消息、通知、任务和审计语义按现有控制面模型重新实现,保留核心字段和用户可见行为。
|
||||
|
||||
## 导航与界面
|
||||
|
||||
iPhone 主导航使用底部 Tab:
|
||||
|
||||
- **节点**:总览、筛选、批量选择、添加/编辑实例、实例详情和设备模块。
|
||||
- **消息**:跨实例短信归档、会话、发送、删除、手动同步和发送队列。
|
||||
- **通知**:通道、规则、队列、投递日志和手动投递/重试。
|
||||
- **日志**:运行日志、连接日志、诊断和审计。
|
||||
- **设置**:实例管理入口、分组/标签、操作目录、维护、备份、应用信息。
|
||||
|
||||
界面移植现有设计语言:
|
||||
|
||||
- 画布使用浅灰 `#f3f5f8`,主要内容使用白色卡片和柔和描边。
|
||||
- 顶部使用深色品牌栏,内容区保持浅色高对比。
|
||||
- 主操作按钮使用现有青绿色系统,危险操作使用现有红色系统。
|
||||
- 统一普通、紧凑、大号控件高度和圆角;同屏并排的主要操作必须尺寸一致。
|
||||
- 使用 SF Symbols 表达刷新、筛选、添加、删除、发送、密钥和状态,不使用文字包住所有图标。
|
||||
- 卡片、表格、表单和确认弹窗按 iPhone 动态字体、安全区、横竖屏和 Assistive Access 语义适配。
|
||||
|
||||
## 功能映射
|
||||
|
||||
### 实例与节点
|
||||
|
||||
- 添加、编辑、删除实例,支持名称、地址、标签、分组和实例密码。
|
||||
- 连接测试、登录、登出、在线/离线/需登录/降级状态和快照新旧程度。
|
||||
- 批量选择、批量测试、批量删除、分组筛选、标签筛选、状态筛选和搜索。
|
||||
- 节点资源摘要、手机号、温度、版本、新鲜度和最新短信预览。
|
||||
- 删除实例前先尝试释放 Hub 绑定;无法访问时按现有规则区分“跳过释放”与“失败”。
|
||||
|
||||
### 设备模块
|
||||
|
||||
实例详情保留上游设备能力模块:总览、蜂窝、SIM、通话、eSIM、WiFi Calling、设备网络、OTA、消息、通知、自动化历史、配置和备份。每个模块按现有上游端点读写,使用统一的加载、空态、无权限、超时和确认流程。
|
||||
|
||||
高风险动作沿用操作目录中的风险等级和确认文案;系统重启、基带重启、服务重启等动作必须明确显示目标实例。
|
||||
|
||||
### 消息
|
||||
|
||||
- 直接拉取实例短信列表、会话、统计和详情。
|
||||
- 发送短信、批量删除、清空、按方向筛选和分页。
|
||||
- 本地归档跨实例消息,保留方向、号码、内容、时间、状态、来源实例和同步时间。
|
||||
- 发送队列保留离线重试记录,但只在前台刷新、进入消息页或用户点击“发送”时处理。
|
||||
|
||||
### 通知
|
||||
|
||||
- 管理通知通道、规则、队列和投递日志。
|
||||
- 支持基于连接状态、短信和操作事件的手动评估。
|
||||
- “立即投递”“重试”“重试全部”“清空”只在应用前台执行。
|
||||
- 投递成功、失败和重试状态进入本地队列/日志表。
|
||||
|
||||
不使用 iOS 远程推送、后台任务调度或常驻网络轮询。
|
||||
|
||||
### 操作、任务与审计
|
||||
|
||||
- 移植操作目录、准备/执行两段式协议、确认令牌、版本检查和风险等级。
|
||||
- 操作准备写入本地数据库;应用退出时把未完成任务标记为终止或未知结果。
|
||||
- 保留任务列表、任务详情、条目结果、尝试记录、取消、重试和手动 reconcile。
|
||||
- 审计事件记录操作者、请求、目标、结果、参数摘要和异常原因。
|
||||
|
||||
定时任务创建与自动触发移除。已有调度模型只保留为历史只读视图;新的“手动执行”入口不创建周期任务。
|
||||
|
||||
### 日志与系统
|
||||
|
||||
- 连接日志、运行日志和诊断汇总进入日志中心。
|
||||
- 支持筛选、清空、修剪、导出和详情展开。
|
||||
- 系统维护显示数据库、组件数量、可回收空间、应用版本和平台信息。
|
||||
- 手动清理、`VACUUM`、校验、备份、恢复、导入、导出和删除备份。
|
||||
- 自动备份、自动日志修剪和自动数据保留调度移除;保留策略仅作为手动清理的默认参数。
|
||||
|
||||
### 分组、标签与身份
|
||||
|
||||
- 设备分组和标签完整本地管理,并用于节点筛选、通知规则和批量操作。
|
||||
- 设备身份记录用于识别异常换绑和重复身份,前台刷新时更新。
|
||||
- 授权模式支持自动与手动;手动模式在检测到身份变化时要求确认。
|
||||
|
||||
## 定时能力的移除边界
|
||||
|
||||
以下行为不实现:
|
||||
|
||||
- 应用启动后的周期网络轮询。
|
||||
- iOS 后台任务、静默推送唤醒、常驻 Socket 或后台同步。
|
||||
- Cron 表达式解析与周期执行。
|
||||
- 自动备份、自动队列投递、自动日志清理。
|
||||
- 控制台自更新。
|
||||
|
||||
以下行为保留:
|
||||
|
||||
- 进入前台、下拉刷新、页面切换或用户点击时执行一次同步。
|
||||
- 用户主动触发队列投递、备份、清理和 reconcile。
|
||||
- 前台期间页面可见时的按需状态刷新。
|
||||
|
||||
## 上游访问与安全
|
||||
|
||||
- 上游请求只允许显式注册的方法和路径,不提供任意 URL 代理。
|
||||
- 响应大小、超时、JSON 结构和敏感字段按现有上游网关边界限制。
|
||||
- 实例密码存入 Keychain;会话 Cookie 只保存在内存,不写入 SQLite 或日志。
|
||||
- 登录会话按实例隔离;修改实例地址后立即清空旧会话。
|
||||
- 日志不输出 Cookie、Authorization、密码、确认令牌、完整短信正文或敏感通知配置。
|
||||
- 短信和通知正文在列表中支持脱敏显示,详情页按现有显隐交互显示原文。
|
||||
|
||||
iOS 允许访问用户明确输入的 HTTP SimAdmin 实例地址。设置页必须显示“HTTP 明文传输”警告;HTTPS 和受信任内网地址不做额外降级提示。
|
||||
|
||||
## 数据模型与迁移
|
||||
|
||||
SQLite 保留以下核心表,字段可按 Swift 领域模型调整,但不得丢失现有用户语义:
|
||||
|
||||
- 实例:`instances`、`instance_tags`、`tag_registry`、`device_groups`。
|
||||
- 状态:`status_snapshots`、`capabilities`、`connection_logs`、`device_identities`。
|
||||
- 消息:`sms_messages`、`sms_outbox`。
|
||||
- 通知:`notification_channels`、`notification_rules`、`notification_deliveries`、`notification_queue`。
|
||||
- 操作:`jobs`、`job_items`、`job_attempts`、`operation_preparations`、`audit_events`。
|
||||
- 设置:`app_settings`、维护保留策略。
|
||||
|
||||
首次启动创建 iOS 版本 1 迁移。不导入现有 Node 服务数据库作为第一版要求;后续可在设置页提供数据库导入兼容层。导入时必须拒绝无法识别的 schema 版本,并在写入前创建安全备份。
|
||||
|
||||
## 构建 IPA
|
||||
|
||||
新增 Swift Package `apps/ios` 和构建脚本 `scripts/build-ios-ipa.sh`。Swift Package 是 iOS 源码与依赖的单一事实来源,不引入 XcodeGen 或检入的 Xcode 工程文件。
|
||||
|
||||
1. 使用 `swift build --configuration release --triple arm64-apple-ios16.0` 和 iPhoneOS SDK 编译 `MultiSimAdmin` 可执行文件。
|
||||
2. 组装 `Payload/MultiSimAdmin.app`,包含可执行文件、`Info.plist`、资源包和应用图标。
|
||||
3. `Info.plist` 声明 iOS 16 最低版本、iPhone 方向、深浅色外观、ATS 例外和启动屏,但不声明后台模式。
|
||||
4. 压缩为 `build/ipa/MultiSimAdmin.ipa`。
|
||||
5. 输出 IPA 路径、大小和 SHA-256。
|
||||
|
||||
构建脚本不得要求证书或描述文件。如果目标环境需要伪签名,由用户使用 TrollStore 或自有工具处理;仓库脚本不默认修改二进制签名。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 网络失败显示实例名、可读原因和重试操作,不中断其他实例刷新。
|
||||
- 单个实例失败只影响该实例卡片或详情,不使节点总览整页失败。
|
||||
- 上游 401 显示“需要登录”,403 显示权限受限,404 显示不支持,超时明确区分网络不可达。
|
||||
- 删除、恢复、重启、清空和高风险操作使用统一确认弹窗,显示目标数量与不可恢复提示。
|
||||
- 数据库迁移失败时保持只读启动屏,提示备份文件位置,不自动删除旧数据。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
XCTest 覆盖:
|
||||
|
||||
- 实例创建、地址校验、版本冲突、密码保存、登录会话隔离和删除补偿。
|
||||
- 上游路径白名单、超时、错误状态、Cookie 注入和响应解析。
|
||||
- 短信归档、去重、发送、删除、队列前台 flush 和失败重试。
|
||||
- 通知规则评估、队列处理、通道投递和日志写入。
|
||||
- 操作准备/执行、确认令牌、任务结果、审计和中断任务恢复。
|
||||
- 数据库迁移、备份导出、恢复预览和敏感字段排除。
|
||||
- UI 层验证核心页面可达、空态/错误态存在、按钮尺寸一致、脱敏状态正确。
|
||||
|
||||
构建验收:
|
||||
|
||||
- iOS 16 Simulator 可安装并启动。
|
||||
- 390、768 和 1024 点宽度下无横向溢出、文字截断或控件重叠。
|
||||
- `scripts/build-ios-ipa.sh` 在干净构建目录产出未签名 IPA,并输出 SHA-256。
|
||||
- 应用不请求定位、相机、麦克风、通讯录或后台刷新权限。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不实现 App Store 发布、TestFlight、推送证书或开发者签名。
|
||||
- 不在 iPhone 上运行 Node.js、Fastify 或现有 SQLite 服务端。
|
||||
- 不实现后台常驻、Cron 自动化、自动备份或控制台自更新。
|
||||
- 不支持多用户、远程 Web 登录或浏览器访问 iPhone 内的控制面。
|
||||
- 不移除实例自身密码能力;只移除 multi-simadmin 的全局访问密码。
|
||||
Reference in New Issue
Block a user