Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef280b53c9 | ||
|
|
3e0bcb1462 | ||
|
|
d81605ebff | ||
|
|
3f6090081f | ||
|
|
a6867bc9cb | ||
|
|
99752bd355 | ||
|
|
95c653b3d3 | ||
|
|
802ddd1102 | ||
|
|
c3d17b3daf | ||
|
|
9ad0c46088 | ||
|
|
36ce791215 | ||
|
|
eb880e0d14 | ||
|
|
ec3ffb9db3 | ||
|
|
3607eddc9f | ||
|
|
27687868ef | ||
|
|
2ff23d1fb0 | ||
|
|
409345cba3 | ||
|
|
e01a105645 | ||
|
|
20632be86a | ||
|
|
0f2866a853 | ||
|
|
31b2f33d15 | ||
|
|
c841748971 | ||
|
|
995206ac23 | ||
|
|
c568e496b2 | ||
|
|
9c3186252b | ||
|
|
cf187e42fa | ||
|
|
84ec3678c0 | ||
|
|
7778693807 | ||
|
|
54eb8dcb86 | ||
|
|
fda3e11f1e | ||
|
|
1caf6985ff | ||
|
|
001659ed91 | ||
|
|
bfe5025943 | ||
|
|
c0adbcbc8a | ||
|
|
61a5aee48d | ||
|
|
70e598208a | ||
|
|
c1be714ba4 | ||
|
|
29a0eee854 | ||
|
|
2977f75129 | ||
|
|
236e78327c | ||
|
|
3c5971e167 | ||
|
|
e4c4bce75b | ||
|
|
c57dc81e42 | ||
|
|
96dd9e2ba6 | ||
|
|
4f42a6f835 | ||
|
|
eed134a25c |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -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 地址。
|
||||
@@ -14,7 +38,7 @@
|
||||
- 融合 [SimAdminHub](https://github.com/3899/SimAdminHub) 的管理思路:跨节点短信、通知、自动化与跨域健康自然融入 Fleet 节点总览体系,无需部署或接入外部 Hub。
|
||||
- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。
|
||||
|
||||
## 一键安装(macOS)
|
||||
## 一键安装(macOS / Linux)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scripts/install.sh | sh
|
||||
@@ -28,7 +52,7 @@ curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scri
|
||||
|
||||
安装完成后按终端输出访问 `http://<本机局域网 IP>:8788/fleet`。首次密码可在当前管理台直接设置。Gateway 默认监听所有网络接口,因此必须确保主机仅接入可信内网或已通过防火墙限制 8788 的来源;HTTP 部署不得直接暴露到公网,公网开放必须由前置代理提供 HTTPS 和访问控制。
|
||||
|
||||
> 当前生产秘密存储使用 macOS Keychain,因此一键安装脚本暂只支持 macOS。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
|
||||
> 秘密存储按平台自动选择:macOS 使用 Keychain,Linux 使用数据目录下 0600 权限的 `secrets.json` 文件;也可用 `MULTI_SIMADMIN_SECRET_BACKEND=macos-keychain|secret-file` 显式指定。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
|
||||
|
||||
### 服务管理
|
||||
|
||||
@@ -43,7 +67,17 @@ sh /tmp/multi-simadmin-install.sh start
|
||||
sh /tmp/multi-simadmin-install.sh uninstall
|
||||
```
|
||||
|
||||
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置为 `~/Library/Application Support/multi-simadmin`,可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
|
||||
`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 节点总览与跨域健康
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
it('renders SMS time and random macros separately for each target', async () => {
|
||||
const db = new Database(':memory:');
|
||||
migrateDatabase(db);
|
||||
const send = vi.fn(async (_instanceId: string, input: { content: string }) => ({
|
||||
const send = vi.fn(async (_instanceId: string, _input: { content: string }) => ({
|
||||
sent: true as const,
|
||||
}));
|
||||
const dispatcher = new ScheduledOperationDispatcher({
|
||||
@@ -17,6 +17,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
operations: { prepare: vi.fn(), execute: vi.fn() },
|
||||
messages: { send },
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: vi.fn(),
|
||||
get: vi.fn(async () =>
|
||||
JSON.stringify({
|
||||
@@ -90,7 +91,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
execute,
|
||||
},
|
||||
messages: { send: vi.fn() },
|
||||
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
repository: new ScheduledTaskRepository(db),
|
||||
});
|
||||
const result = await dispatcher.dispatch(
|
||||
@@ -179,6 +180,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
operations: { prepare: vi.fn(), execute: vi.fn() },
|
||||
messages: { send },
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: vi.fn(),
|
||||
get: vi.fn(async () =>
|
||||
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
|
||||
@@ -216,7 +218,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }),
|
||||
},
|
||||
messages: { send: vi.fn() },
|
||||
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
repository: new ScheduledTaskRepository(db),
|
||||
});
|
||||
const result = await dispatcher.dispatch(
|
||||
|
||||
@@ -114,6 +114,14 @@ export class ScheduledOperationDispatcher {
|
||||
const schema = operationSchemas[task.operationType];
|
||||
if (!operationId || !schema)
|
||||
return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] };
|
||||
// The execution baseline pins delay_seconds to 3; anything else could never
|
||||
// succeed, so refuse up front instead of exhausting the retry policy.
|
||||
if (task.operationType === 'reboot-system' && (task.delaySeconds ?? 3) !== 3)
|
||||
return {
|
||||
outcome: 'needs-attention',
|
||||
reason: 'Scheduled reboots only support the pinned 3-second delay',
|
||||
jobIds: [],
|
||||
};
|
||||
const fields =
|
||||
task.operationType === 'reboot-system'
|
||||
? [
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js';
|
||||
import { ScheduledTaskService } from './scheduled-task-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
|
||||
blockRotations = false;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConnectionProbe } from './connection-probe.js';
|
||||
import { ConnectionLogService } from '../system/connection-log-service.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('InstanceCredentialResolver', () => {
|
||||
it('resolves only the canonical password reference for the requested existing instance', async () => {
|
||||
let asked: string | undefined;
|
||||
const { db, resolver } = fixture({
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async (reference) => {
|
||||
asked = reference;
|
||||
@@ -44,6 +45,7 @@ describe('InstanceCredentialResolver', () => {
|
||||
it('does not call SecretStore when no saved credential exists', async () => {
|
||||
let calls = 0;
|
||||
const { resolver } = fixture({
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => {
|
||||
calls += 1;
|
||||
@@ -57,6 +59,7 @@ describe('InstanceCredentialResolver', () => {
|
||||
it('rejects a reference bound to another instance before SecretStore access', async () => {
|
||||
let calls = 0;
|
||||
const { db, resolver } = fixture({
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => {
|
||||
calls += 1;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
||||
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
export class CredentialResolverError extends Error {
|
||||
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
|
||||
super(code);
|
||||
@@ -24,10 +23,12 @@ export class InstanceCredentialResolver {
|
||||
.prepare(
|
||||
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
|
||||
)
|
||||
.get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined;
|
||||
.get(instanceId, PURPOSE, this.options.store.provider) as
|
||||
| { external_reference: string }
|
||||
| undefined;
|
||||
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
|
||||
try {
|
||||
const parsed = parseKeychainReference(row.external_reference);
|
||||
const parsed = parseSecretReference(row.external_reference);
|
||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
|
||||
throw new Error('invalid binding');
|
||||
const secret = await this.options.store.get(row.external_reference);
|
||||
|
||||
@@ -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') < ?")
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Instance } from '@multi-simadmin/contracts';
|
||||
import { InstanceService, InstanceServiceError } from './instance-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
readonly deletes: string[] = [];
|
||||
readonly sets: Array<{
|
||||
|
||||
@@ -10,10 +10,9 @@ import type {
|
||||
SnapshotFreshness,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
||||
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
const MAX_TAGS = 50;
|
||||
const MAX_TAG_LENGTH = 100;
|
||||
|
||||
@@ -408,7 +407,7 @@ export class InstanceService {
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
|
||||
.run(id, instanceId, PURPOSE, this.store.provider, external, now, now);
|
||||
}
|
||||
private async storeSecret(
|
||||
instanceId: string,
|
||||
@@ -422,7 +421,7 @@ export class InstanceService {
|
||||
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
||||
}
|
||||
try {
|
||||
const parsed = parseKeychainReference(external);
|
||||
const parsed = parseSecretReference(external);
|
||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
|
||||
throw new Error('secret store returned a reference with an invalid binding');
|
||||
} catch {
|
||||
@@ -504,7 +503,7 @@ export class InstanceService {
|
||||
AND secret_cleanup_tasks.purpose=excluded.purpose
|
||||
AND secret_cleanup_tasks.provider=excluded.provider`,
|
||||
)
|
||||
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
|
||||
.run(reference, instanceId, PURPOSE, this.store.provider, now, now);
|
||||
if (queued.changes !== 1)
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
|
||||
}
|
||||
@@ -529,13 +528,13 @@ export class InstanceService {
|
||||
);
|
||||
}
|
||||
private validateCleanupEntry(entry: CleanupEntry): void {
|
||||
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
|
||||
if (entry.purpose !== PURPOSE || entry.provider !== this.store.provider)
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
||||
this.validateCleanupReference(entry.instanceId, entry.reference);
|
||||
}
|
||||
private validateCleanupReference(instanceId: string, reference: string): void {
|
||||
try {
|
||||
const parsed = parseKeychainReference(reference);
|
||||
const parsed = parseSecretReference(reference);
|
||||
if (
|
||||
parsed.instanceId !== instanceId ||
|
||||
parsed.purpose !== PURPOSE ||
|
||||
|
||||
@@ -39,6 +39,7 @@ function pendingDatabase(): Database.Database {
|
||||
}
|
||||
|
||||
class FakeStore implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly sets: { key: SecretKey; value: string }[] = [];
|
||||
readonly deletes: string[] = [];
|
||||
setError?: Error;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getManagedDatabaseIdentity } from '../../infrastructure/database/databa
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
|
||||
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
|
||||
|
||||
@@ -163,7 +162,7 @@ async function activatePendingSecretExclusive({
|
||||
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
|
||||
db.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now);
|
||||
).run(id, instanceId, PURPOSE, store.provider, externalReference, now, now);
|
||||
const updated = db
|
||||
.prepare(
|
||||
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './central-notification-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||
|
||||
@@ -559,7 +559,28 @@ export class CentralNotificationService {
|
||||
|
||||
async deleteChannel(id: string): Promise<void> {
|
||||
const channel = this.getChannel(id);
|
||||
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id);
|
||||
this.#db.transaction(() => {
|
||||
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id);
|
||||
// Rules keep channel ids in a JSON column, so no FK cascade applies; a
|
||||
// dangling id would make every referencing rule unreadable and drop
|
||||
// future notifications on the floor.
|
||||
const rules = this.#db
|
||||
.prepare('SELECT id, channel_ids_json FROM notification_rules')
|
||||
.all() as Array<{ id: string; channel_ids_json: string }>;
|
||||
for (const rule of rules) {
|
||||
let ids: unknown;
|
||||
try {
|
||||
ids = JSON.parse(String(rule.channel_ids_json));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(ids) || !ids.includes(id)) continue;
|
||||
const next = JSON.stringify(ids.filter((entry) => entry !== id));
|
||||
this.#db
|
||||
.prepare('UPDATE notification_rules SET channel_ids_json=?, updated_at=? WHERE id=?')
|
||||
.run(next, this.#now(), rule.id);
|
||||
}
|
||||
})();
|
||||
if (channel.secretReference)
|
||||
await this.#options.store.delete(channel.secretReference).catch(() => false);
|
||||
}
|
||||
@@ -1202,6 +1223,19 @@ export class CentralNotificationService {
|
||||
return { processed: succeeded + failed, succeeded, failed };
|
||||
}
|
||||
|
||||
/** Startup sweep: rows claimed as 'sending' before a crash would never drain again. */
|
||||
reconcileInterrupted(): number {
|
||||
return Number(
|
||||
this.#db
|
||||
.prepare(
|
||||
`UPDATE notification_queue
|
||||
SET status='pending',updated_at=?
|
||||
WHERE status='sending'`,
|
||||
)
|
||||
.run(this.#now()).changes,
|
||||
);
|
||||
}
|
||||
|
||||
retryQueueItem(id: string): void {
|
||||
const result = this.#db
|
||||
.prepare(
|
||||
@@ -1214,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');
|
||||
@@ -1338,7 +1386,12 @@ export class CentralNotificationService {
|
||||
#queueItem(row: Record<string, unknown>): NotificationQueueItem {
|
||||
const status = String(row.status) as NotificationQueueItem['status'];
|
||||
const attempts = Number(row.attempts ?? 0);
|
||||
const payload = record(JSON.parse(String(row.payload_json))) ?? {};
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = record(JSON.parse(String(row.payload_json))) ?? {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const deliveredAt =
|
||||
typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined;
|
||||
const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined;
|
||||
@@ -1560,7 +1613,12 @@ export class CentralNotificationService {
|
||||
enabled: row.enabled === 1,
|
||||
condition: parseJson(String(row.condition_json), this.#condition.bind(this)),
|
||||
scope: parseJson(String(row.scope_json), this.#scope.bind(this)),
|
||||
channels: Object.freeze(channelIds.map((id) => this.getChannel(id))),
|
||||
channels: Object.freeze(
|
||||
channelIds.flatMap((id) => {
|
||||
const row = this.#row(`SELECT * FROM notification_channels WHERE id = ?`, id);
|
||||
return row ? [this.#channel(row)] : [];
|
||||
}),
|
||||
),
|
||||
templates: parseJson(String(row.templates_json), this.#templates.bind(this)),
|
||||
rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit),
|
||||
quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours),
|
||||
|
||||
@@ -160,6 +160,8 @@ async function defaultRequester(request: HttpRequest, timeoutMs: number): Promis
|
||||
headers: request.headers,
|
||||
...(request.body === undefined ? {} : { body: request.body }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
// A signed payload must never be replayed to a redirect target.
|
||||
redirect: 'manual',
|
||||
});
|
||||
const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
|
||||
return { status: response.status, body: text };
|
||||
|
||||
@@ -220,6 +220,9 @@ export async function sendEmailNotification(
|
||||
const receivers = splitAddresses(configText(input.config, 'receiver_addresses'));
|
||||
if (!host) return { ok: false, detail: '缺少 SMTP 地址' };
|
||||
if (!sender) return { ok: false, detail: '缺少发件地址' };
|
||||
// Envelope commands are line-framed; reject anything that could break the frame.
|
||||
if (!/^[^\s@<>,;:"']+@[^\s@<>,;:"']+$/u.test(sender))
|
||||
return { ok: false, detail: '发件地址无效' };
|
||||
if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' };
|
||||
const port = configNumber(input.config, 'smtp_port', 465);
|
||||
const security = configText(input.config, 'smtp_security') || 'implicit_tls';
|
||||
|
||||
@@ -80,7 +80,9 @@ describe('MetricsService', () => {
|
||||
const text = metrics.render();
|
||||
expect(text).toContain('multi_simadmin_nodes_total 2');
|
||||
expect(text).toContain('multi_simadmin_node_enabled{node="node-b"} 0');
|
||||
expect(text).toContain('multi_simadmin_node_info{node="node-a",name="机房 A"} 1');
|
||||
expect(text).toContain('multi_simadmin_node_info{node="node-a"} 1');
|
||||
// Operator-assigned names stay out of the session-free exposition entirely.
|
||||
expect(text).not.toContain('机房 A');
|
||||
expect(text).toContain('multi_simadmin_node_probe_total{node="node-a",outcome="success"} 38');
|
||||
expect(text).toContain('multi_simadmin_node_availability_ratio{node="node-a"} 0.950');
|
||||
expect(text).toContain('multi_simadmin_sms_outbox{status="failed"} 1');
|
||||
@@ -108,14 +110,15 @@ describe('MetricsService', () => {
|
||||
expect(text).toContain('multi_simadmin_identity_pending_total 2');
|
||||
});
|
||||
|
||||
it('escapes label values so a node name cannot forge a series', () => {
|
||||
it('never emits a node name, so a hostile name cannot forge a series', () => {
|
||||
const { db: database, metrics } = fixture();
|
||||
seedNode(database, 'node-a', 'evil" name\nwith break');
|
||||
|
||||
const text = metrics.render();
|
||||
expect(text).toContain('name="evil\\" name\\nwith break"');
|
||||
// The injected newline must not become its own sample line.
|
||||
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(1);
|
||||
// Names are excluded from the session-free exposition outright; escaping a
|
||||
// label is not the right tool for untrusted operator input.
|
||||
expect(text).not.toContain('evil"');
|
||||
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits a valid exposition: help and type precede every series', () => {
|
||||
|
||||
@@ -77,9 +77,9 @@ export class MetricsService {
|
||||
]),
|
||||
);
|
||||
|
||||
const info = nodes.map(
|
||||
(node) => `${PREFIX}_node_info{node="${label(node.id)}",name="${label(node.name)}"} 1`,
|
||||
);
|
||||
// Operator-assigned names can leak hostnames/locations to unauthenticated
|
||||
// scrapers; the series intentionally stays opaque to node ids.
|
||||
const info = nodes.map((node) => `${PREFIX}_node_info{node="${label(node.id)}"} 1`);
|
||||
lines.push(
|
||||
...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info),
|
||||
...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './delete-instance-operation.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
async set() {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ConsoleUpdateService,
|
||||
UpdateError,
|
||||
createLaunchdRestartLauncher,
|
||||
createPlatformRestartLauncher,
|
||||
createSystemdRestartLauncher,
|
||||
parseManifestVersion,
|
||||
parseShortStat,
|
||||
type UpdateCommandResult,
|
||||
@@ -466,3 +468,76 @@ describe('createLaunchdRestartLauncher', () => {
|
||||
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSystemdRestartLauncher', () => {
|
||||
it('is unsupported without a unit or with a malformed unit name', () => {
|
||||
for (const unit of [undefined, '', 'evil; rm -rf /', 'multi-simadmin']) {
|
||||
const launcher = createSystemdRestartLauncher({
|
||||
env: unit === undefined ? {} : { MULTI_SIMADMIN_SYSTEMD_UNIT: unit },
|
||||
runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) },
|
||||
});
|
||||
expect(launcher.supported).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('restarts through the configured scope and unit', async () => {
|
||||
const calls: string[][] = [];
|
||||
const launcher = createSystemdRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service' },
|
||||
runner: {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(launcher.supported).toBe(true);
|
||||
await launcher.restart();
|
||||
expect(calls[0]).toEqual(['systemctl', 'user', 'restart', 'multi-simadmin-api.service']);
|
||||
});
|
||||
|
||||
it('supports the system scope and surfaces systemctl failures', async () => {
|
||||
const calls: string[][] = [];
|
||||
const launcher = createSystemdRestartLauncher({
|
||||
env: {
|
||||
MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service',
|
||||
MULTI_SIMADMIN_SYSTEMD_SCOPE: 'system',
|
||||
},
|
||||
runner: {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { code: 1, stdout: '', stderr: 'not logged in' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await launcher.restart().catch(() => {});
|
||||
expect(calls[0]).toEqual(['systemctl', 'system', 'restart', 'multi-simadmin-api.service']);
|
||||
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlatformRestartLauncher', () => {
|
||||
const runner = { run: async () => ({ code: 0, stdout: '', stderr: '' }) };
|
||||
|
||||
it('prefers systemd on Linux when a unit is configured', () => {
|
||||
const launcher = createPlatformRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux', MULTI_SIMADMIN_SYSTEMD_UNIT: 'api.service' },
|
||||
runner,
|
||||
});
|
||||
expect(launcher.supported).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the launchd launcher everywhere else', () => {
|
||||
const linux = createPlatformRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux' },
|
||||
runner,
|
||||
});
|
||||
const darwin = createPlatformRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'darwin', XPC_SERVICE_NAME: 'x' },
|
||||
runner,
|
||||
uid: () => 501,
|
||||
});
|
||||
expect(linux.supported).toBe(false);
|
||||
expect(darwin.supported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,51 @@ export function createLaunchdRestartLauncher(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux systemd counterpart to the launchd launcher. The unit and scope are
|
||||
* supplied explicitly through the environment so a supervised deployment can
|
||||
* hand restarts to its service manager; unset variables keep it unsupported.
|
||||
*/
|
||||
export function createSystemdRestartLauncher(input: {
|
||||
readonly env: Readonly<Record<string, string | undefined>>;
|
||||
readonly runner: UpdateCommandRunner;
|
||||
}): RestartLauncher {
|
||||
const unit = input.env.MULTI_SIMADMIN_SYSTEMD_UNIT ?? '';
|
||||
const scope = input.env.MULTI_SIMADMIN_SYSTEMD_SCOPE === 'system' ? 'system' : 'user';
|
||||
const validUnit = /^[A-Za-z0-9@_.\-]+\.service$/.test(unit);
|
||||
const supported = validUnit;
|
||||
return {
|
||||
supported,
|
||||
async restart() {
|
||||
if (!supported)
|
||||
throw new UpdateError(
|
||||
'NOT_SUPPORTED',
|
||||
'未配置 systemd 服务单元(MULTI_SIMADMIN_SYSTEMD_UNIT)。',
|
||||
501,
|
||||
);
|
||||
const result = await input.runner.run('systemctl', [scope, 'restart', unit]);
|
||||
if (result.code !== 0)
|
||||
throw new UpdateError(
|
||||
'COMMAND',
|
||||
text(result.stderr, 200) || 'systemctl restart 失败。',
|
||||
500,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Picks the restart launcher matching the host service manager. */
|
||||
export function createPlatformRestartLauncher(input: {
|
||||
readonly env: Readonly<Record<string, string | undefined>>;
|
||||
readonly runner: UpdateCommandRunner;
|
||||
readonly uid?: () => number | undefined;
|
||||
}): RestartLauncher {
|
||||
const platform = input.env.MULTI_SIMADMIN_HOST_PLATFORM ?? process.platform;
|
||||
return platform === 'linux' && input.env.MULTI_SIMADMIN_SYSTEMD_UNIT !== undefined
|
||||
? createSystemdRestartLauncher(input)
|
||||
: createLaunchdRestartLauncher(input);
|
||||
}
|
||||
|
||||
export interface ConsoleUpdateServiceOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly version: string;
|
||||
|
||||
@@ -107,6 +107,9 @@ function trustedUpstreamHeaders(
|
||||
delete sanitized[GATEWAY_CLIENT_IP_HEADER];
|
||||
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
|
||||
if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp;
|
||||
// The browser connection's protocol is the gateway's to declare; the API uses
|
||||
// it to decide whether session cookies may carry the Secure flag.
|
||||
sanitized['x-forwarded-proto'] = (headers['x-forwarded-proto'] as string | undefined) ?? 'http';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,9 @@ afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((fn) => fn()));
|
||||
});
|
||||
|
||||
describe.sequential('executable package runtimes', () => {
|
||||
// The runtime lifecycle under test is macOS/POSIX-only: process-group SIGTERM,
|
||||
// lsof port inspection, and corepack as a direct spawn target.
|
||||
describe.sequential('executable package runtimes', { skip: process.platform === 'win32' }, () => {
|
||||
it('makes the exact production gateway executable attempt 8788 only with acknowledgement', async () => {
|
||||
await expectPortFree(8788);
|
||||
const fixture = await listenOn(8788);
|
||||
|
||||
@@ -9,7 +9,12 @@ it('registers the read-only operations catalog without touching upstream', async
|
||||
let upstreamCalls = 0;
|
||||
const app = buildControlPlaneApp({
|
||||
db,
|
||||
store: { set: async () => '', get: async () => undefined, delete: async () => false },
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => undefined,
|
||||
delete: async () => false,
|
||||
},
|
||||
upstream: {
|
||||
get: async () => {
|
||||
upstreamCalls += 1;
|
||||
|
||||
@@ -13,6 +13,7 @@ afterEach(async () => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
class Store implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
private readonly values = new Map<string, string>();
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||
const account = Buffer.from(
|
||||
|
||||
@@ -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,
|
||||
@@ -320,6 +327,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
smsOutbox.reconcileInterrupted();
|
||||
centralNotifications.reconcileInterrupted();
|
||||
// Removal follows the hub rule the operator already knows: an online device is told to release
|
||||
// its own binding first and a refusal keeps the record, while a device that cannot be reached
|
||||
// at all is forgotten locally. The closure is late-bound because the device action service is
|
||||
@@ -346,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 (
|
||||
@@ -380,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);
|
||||
@@ -509,6 +524,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
resources,
|
||||
messages,
|
||||
registerDeletionPreparationRoute: false,
|
||||
onEvent: (envelope) => eventJournal.append(envelope),
|
||||
});
|
||||
registerFleetRoutes(app, {
|
||||
instances,
|
||||
@@ -581,6 +597,31 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
void Promise.resolve()
|
||||
.then(() => centralNotifications.pruneLogs())
|
||||
.catch(() => undefined);
|
||||
// Delivered and abandoned outbox rows otherwise accumulate forever.
|
||||
void Promise.resolve()
|
||||
.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;
|
||||
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
|
||||
|
||||
@@ -23,7 +23,7 @@ const STATE_FILE = 'cutover-state.json';
|
||||
const GATEWAY_ARGV = [
|
||||
process.execPath,
|
||||
'--import',
|
||||
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
|
||||
'tsx',
|
||||
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
|
||||
] as const;
|
||||
const DIGEST = /^[a-f0-9]{64}$/u;
|
||||
@@ -289,11 +289,14 @@ async function durableState(plan: CutoverPlan, state: CutoverState): Promise<voi
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, target);
|
||||
const directory = await open(plan.stateDir, constants.O_RDONLY);
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
if (process.platform !== 'win32') {
|
||||
// directory fsync only exists on POSIX
|
||||
const directory = await open(plan.stateDir, constants.O_RDONLY);
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +314,8 @@ function preparedState(plan: CutoverPlan): CutoverState {
|
||||
async function readState(plan: CutoverPlan): Promise<CutoverState> {
|
||||
const path = join(plan.stateDir, STATE_FILE);
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || (info.mode & 0o777) !== 0o600)
|
||||
// Windows keeps no POSIX mode bits (chmod is a no-op); ACLs govern access there.
|
||||
if (!info.isFile() || (process.platform !== 'win32' && (info.mode & 0o777) !== 0o600))
|
||||
throw new Error('Cutover state must be a mode-0600 regular file');
|
||||
const input = record(JSON.parse(await readFile(path, 'utf8')) as unknown, 'Cutover state');
|
||||
const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart'];
|
||||
|
||||
@@ -48,10 +48,13 @@ describe('verified backup, restore, and rollback foundation', () => {
|
||||
]);
|
||||
copy.close();
|
||||
expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== 'win32') expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('verifies a separate candidate and digest-binds activation', async () => {
|
||||
// Activation renames a WAL file that a live connection still holds open;
|
||||
// POSIX permits that, Windows file locking does not.
|
||||
const itPosix = process.platform === 'win32' ? it.skip : it;
|
||||
itPosix('verifies a separate candidate and digest-binds activation', async () => {
|
||||
const { livePath, backupPath, database } = await fixture();
|
||||
const snapshot = await backupDatabase(database, backupPath);
|
||||
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
|
||||
@@ -118,7 +121,7 @@ describe('verified backup, restore, and rollback foundation', () => {
|
||||
expect(await readFile(livePath)).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
|
||||
itPosix('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
|
||||
const { livePath, backupPath, database } = await fixture();
|
||||
await backupDatabase(database, backupPath);
|
||||
database.close();
|
||||
|
||||
@@ -159,7 +159,8 @@ async function digestFileHandle(handle: FileHandle): Promise<string> {
|
||||
}
|
||||
|
||||
async function syncFile(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
// Windows FlushFileBuffers requires a writable handle; POSIX allows fsync on 'r'.
|
||||
const handle = await open(path, process.platform === 'win32' ? 'r+' : 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
@@ -168,6 +169,7 @@ async function syncFile(path: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
if (process.platform === 'win32') return; // directory fsync only exists on POSIX
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
@@ -264,7 +266,9 @@ export async function activateRestoreCandidate(
|
||||
await assertValidDatabase(stagedPath);
|
||||
if ((await digestFileHandle(stagedHandle)) !== stagedDigest)
|
||||
throw new Error('Staged restore candidate changed during validation');
|
||||
await stagedHandle.sync();
|
||||
// The staged handle is intentionally read-only after chmod(0400); Windows
|
||||
// FlushFileBuffers requires a writable handle, so skip the durability sync there.
|
||||
if (process.platform !== 'win32') await stagedHandle.sync();
|
||||
await activateStagedDatabase(
|
||||
databasePath,
|
||||
stagedPath,
|
||||
|
||||
@@ -362,7 +362,8 @@ describe('database backup and restore', () => {
|
||||
migrateDatabase(database);
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
await backupDatabase(database, backupPath);
|
||||
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
// Windows keeps no POSIX mode bits; the 0600 guarantee is POSIX-only.
|
||||
if (process.platform !== 'win32') expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute } from 'node:path';
|
||||
import {
|
||||
accountFor,
|
||||
parseSecretAccount,
|
||||
validateKey,
|
||||
SecretReferenceError,
|
||||
} from './secret-reference.js';
|
||||
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||
|
||||
const PROVIDER = 'secret-file';
|
||||
const SERVICE = 'multi-simadmin';
|
||||
const MAX_SECRET_BYTES = 16_384;
|
||||
const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type FileSecretStoreErrorCode =
|
||||
| 'INVALID_KEY'
|
||||
| 'INVALID_SECRET'
|
||||
| 'INVALID_REFERENCE'
|
||||
| 'INVALID_STORE_PATH'
|
||||
| 'SIZE_LIMIT'
|
||||
| 'PAYLOAD_INVALID';
|
||||
|
||||
export class FileSecretStoreError extends Error {
|
||||
constructor(
|
||||
readonly code: FileSecretStoreErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FileSecretStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(code: FileSecretStoreErrorCode, message: string): never {
|
||||
throw new FileSecretStoreError(code, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX filesystem-backed secret store for hosts without a system keyring
|
||||
* (e.g. Linux servers). Secrets live in a single 0600 JSON map under the data
|
||||
* root, replaced atomically via temp file + rename, with writes serialized
|
||||
* in-process.
|
||||
*/
|
||||
export class FileSecretStore implements SecretStore {
|
||||
readonly provider = PROVIDER;
|
||||
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(readonly filePath: string) {
|
||||
if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath))
|
||||
invalid('INVALID_STORE_PATH', 'Secret file path must be absolute');
|
||||
}
|
||||
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
/[\0\r\n]/.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES
|
||||
) {
|
||||
invalid('INVALID_SECRET', 'Secret is not valid for file storage');
|
||||
}
|
||||
validateKey(key);
|
||||
const account = accountFor(key);
|
||||
await this.enqueue(() => this.withStore((store) => ({ ...store, [account]: value })));
|
||||
return `secret-file://${SERVICE}/${account}`;
|
||||
}
|
||||
|
||||
async get(reference: string): Promise<string | undefined> {
|
||||
const { account } = this.parse(reference);
|
||||
const store = await this.readStore();
|
||||
return store[account];
|
||||
}
|
||||
|
||||
async delete(reference: string): Promise<boolean> {
|
||||
const { account } = this.parse(reference);
|
||||
let removed = false;
|
||||
await this.enqueue(() =>
|
||||
this.withStore((store) => {
|
||||
if (!(account in store)) return store;
|
||||
removed = true;
|
||||
const next = { ...store };
|
||||
delete next[account];
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
return removed;
|
||||
}
|
||||
|
||||
private parse(reference: string): { account: string } {
|
||||
if (typeof reference !== 'string' || reference.length > 512)
|
||||
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||
const match = /^secret-file:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||
try {
|
||||
parseSecretAccount(match[1], PROVIDER);
|
||||
} catch (error) {
|
||||
if (error instanceof SecretReferenceError)
|
||||
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||
throw error;
|
||||
}
|
||||
return { account: match[1] };
|
||||
}
|
||||
|
||||
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.queue.then(task, task);
|
||||
this.queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
private async readStore(): Promise<Record<string, string>> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(this.filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw error;
|
||||
}
|
||||
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES)
|
||||
invalid('SIZE_LIMIT', 'Secret file exceeds the size limit');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
|
||||
}
|
||||
if (!isPlainStringMap(parsed)) invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Reads the current map, applies `mutate`, and durably replaces the file. */
|
||||
private async withStore(
|
||||
mutate: (store: Record<string, string>) => Record<string, string>,
|
||||
): Promise<void> {
|
||||
const current = await this.readStore();
|
||||
const next = mutate(current);
|
||||
const directory = dirname(this.filePath);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') {
|
||||
const info = await stat(directory);
|
||||
if (info.isDirectory() && (info.mode & 0o077) !== 0) await chmod(directory, 0o700);
|
||||
}
|
||||
const temporary = `${this.filePath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temporary, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
// Windows FlushFileBuffers is skipped along with the directory fsync below;
|
||||
// durability anchoring is POSIX-only by design.
|
||||
if (process.platform !== 'win32') await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
try {
|
||||
await rename(temporary, this.filePath);
|
||||
if (process.platform !== 'win32') {
|
||||
const directoryHandle = await open(directory, 'r');
|
||||
try {
|
||||
await directoryHandle.sync();
|
||||
} finally {
|
||||
await directoryHandle.close();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainStringMap(value: unknown): value is Record<string, string> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
return Object.values(value).every((entry) => typeof entry === 'string');
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { accountFor, parseSecretAccount, SecretReferenceError } from './secret-reference.js';
|
||||
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||
|
||||
const SECURITY_PATH = '/usr/bin/security';
|
||||
const SERVICE = 'multi-simadmin';
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const MAX_STDOUT_BYTES = 65_536;
|
||||
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type SecretStoreErrorCode =
|
||||
| 'INVALID_KEY'
|
||||
@@ -105,43 +105,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne
|
||||
throw new SecretStoreError(code, message);
|
||||
}
|
||||
|
||||
function validateKey(key: SecretKey): void {
|
||||
if (
|
||||
typeof key.instanceId !== 'string' ||
|
||||
key.instanceId.length === 0 ||
|
||||
key.instanceId.length > 128 ||
|
||||
!safeComponent.test(key.instanceId)
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key instance id is invalid');
|
||||
}
|
||||
if (
|
||||
typeof key.purpose !== 'string' ||
|
||||
key.purpose.length === 0 ||
|
||||
key.purpose.length > 64 ||
|
||||
!safeComponent.test(key.purpose)
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key purpose is invalid');
|
||||
}
|
||||
if (
|
||||
key.slot !== undefined &&
|
||||
(typeof key.slot !== 'string' ||
|
||||
key.slot.length === 0 ||
|
||||
key.slot.length > 64 ||
|
||||
!safeComponent.test(key.slot))
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key slot is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function accountFor(key: SecretKey): string {
|
||||
validateKey(key);
|
||||
const components =
|
||||
key.slot === undefined
|
||||
? [key.instanceId, key.purpose]
|
||||
: [key.instanceId, key.purpose, key.slot];
|
||||
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function referenceFor(account: string): string {
|
||||
return `keychain://${SERVICE}/${account}`;
|
||||
}
|
||||
@@ -152,28 +115,26 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
|
||||
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const account = match[1];
|
||||
let decoded: unknown;
|
||||
let parsed;
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
parsed = parseSecretAccount(account, 'macos-keychain');
|
||||
} catch (error) {
|
||||
if (error instanceof SecretReferenceError)
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
throw error;
|
||||
}
|
||||
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const [instanceId, purpose, slot] = decoded;
|
||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||
validateKey(key);
|
||||
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
return {
|
||||
service: SERVICE,
|
||||
account,
|
||||
instanceId: key.instanceId,
|
||||
purpose: key.purpose,
|
||||
...(key.slot === undefined ? {} : { slot: key.slot }),
|
||||
instanceId: parsed.instanceId,
|
||||
purpose: parsed.purpose,
|
||||
...(parsed.slot === undefined ? {} : { slot: parsed.slot }),
|
||||
};
|
||||
}
|
||||
|
||||
export class MacOSKeychainSecretStore implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
|
||||
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
|
||||
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile, chmod } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FileSecretStore, FileSecretStoreError } from './file-secret-store.js';
|
||||
import { SecretReferenceError } from './secret-reference.js';
|
||||
import {
|
||||
createDefaultSecretStore,
|
||||
defaultSecretFileMetadataCheck,
|
||||
resolveSecretBackend,
|
||||
SecretBackendError,
|
||||
} from './secret-backend.js';
|
||||
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||
|
||||
describe('FileSecretStore', () => {
|
||||
it('round-trips set/get/delete with stable secret-file references', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||
const reference = await store.set(
|
||||
{ instanceId: 'device-1', purpose: 'instance-password', slot: 'slot-1' },
|
||||
'pa$$word',
|
||||
);
|
||||
expect(reference).toMatch(/^secret-file:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/);
|
||||
expect(await store.get(reference)).toBe('pa$$word');
|
||||
expect(await store.delete(reference)).toBe(true);
|
||||
expect(await store.get(reference)).toBeUndefined();
|
||||
expect(await store.delete(reference)).toBe(false);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty, framing, oversize, and malformed keys/values', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||
await expect(store.set({ instanceId: 'x', purpose: 'p' }, '')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
await expect(store.set({ instanceId: 'x', purpose: 'p' }, 'a\nb')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
await expect(
|
||||
store.set({ instanceId: 'x', purpose: 'p' }, 'a'.repeat(16_385)),
|
||||
).rejects.toThrow(FileSecretStoreError);
|
||||
await expect(store.set({ instanceId: '', purpose: 'p' }, 'v')).rejects.toThrow(
|
||||
SecretReferenceError,
|
||||
);
|
||||
await expect(store.get('https://evil.example/one')).rejects.toThrow(FileSecretStoreError);
|
||||
await expect(store.get('secret-file://other-service/abc')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the map private (0600) and leaves no temporary files behind', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const path = join(directory, 'nested', 'secrets.json');
|
||||
const store = new FileSecretStore(path);
|
||||
const reference = await store.set({ instanceId: 'x', purpose: 'p', slot: 's' }, 'secret');
|
||||
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
expect(await readdir(join(directory, 'nested'))).toEqual(['secrets.json']);
|
||||
const raw = await readFile(path, 'utf8');
|
||||
expect(JSON.parse(raw)).toEqual({
|
||||
[reference.slice('secret-file://multi-simadmin/'.length)]: 'secret',
|
||||
});
|
||||
expect(await store.get(reference)).toBe('secret');
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes concurrent writes so every mutation lands', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||
const references = await Promise.all(
|
||||
Array.from({ length: 25 }, (_, index) =>
|
||||
store.set({ instanceId: 'x', purpose: `p${index}`, slot: 's' }, `v${index}`),
|
||||
),
|
||||
);
|
||||
for (const [index, reference] of references.entries())
|
||||
expect(await store.get(reference)).toBe(`v${index}`);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a corrupted or non-map payload instead of failing open', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const path = join(directory, 'secrets.json');
|
||||
await writeFile(path, 'not json', 'utf8');
|
||||
const store = new FileSecretStore(path);
|
||||
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
await writeFile(path, '{"account": 42}', 'utf8');
|
||||
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
expect(() => new FileSecretStore('relative/secrets.json')).toThrow(FileSecretStoreError);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret backend selection', () => {
|
||||
it('defaults to the keychain on darwin and the file store elsewhere', () => {
|
||||
expect(resolveSecretBackend({}, 'darwin')).toBe('macos-keychain');
|
||||
expect(resolveSecretBackend({}, 'linux')).toBe('secret-file');
|
||||
expect(resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'secret-file' }, 'darwin')).toBe(
|
||||
'secret-file',
|
||||
);
|
||||
expect(() =>
|
||||
resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'vault' }, 'darwin'),
|
||||
).toThrow(SecretBackendError);
|
||||
});
|
||||
|
||||
it('builds the matching store and refuses keychain off macOS', () => {
|
||||
const directory = join(tmpdir(), 'msa-backend-selection');
|
||||
expect(
|
||||
createDefaultSecretStore({
|
||||
env: {},
|
||||
secretFilePath: join(directory, 's.json'),
|
||||
platform: 'linux',
|
||||
}),
|
||||
).toBeInstanceOf(FileSecretStore);
|
||||
expect(
|
||||
createDefaultSecretStore({
|
||||
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
|
||||
secretFilePath: join(directory, 's.json'),
|
||||
platform: 'darwin',
|
||||
}),
|
||||
).toBeInstanceOf(MacOSKeychainSecretStore);
|
||||
expect(() =>
|
||||
createDefaultSecretStore({
|
||||
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
|
||||
secretFilePath: join(directory, 's.json'),
|
||||
platform: 'linux',
|
||||
}),
|
||||
).toThrow(SecretBackendError);
|
||||
});
|
||||
|
||||
it('file metadata probe accepts a missing or private file and rejects a public one', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-meta-'));
|
||||
try {
|
||||
const path = join(directory, 'secrets.json');
|
||||
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
|
||||
await writeFile(path, '{}\n', { mode: 0o600 });
|
||||
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(path, 0o644);
|
||||
expect(await defaultSecretFileMetadataCheck(path)).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { FileSecretStore } from './file-secret-store.js';
|
||||
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||
import type { SecretStore } from './secret-store.js';
|
||||
|
||||
export type SecretBackend = 'macos-keychain' | 'secret-file';
|
||||
|
||||
const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file'];
|
||||
|
||||
export class SecretBackendError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecretBackendError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override,
|
||||
* otherwise the macOS Keychain on darwin and the 0600 file store elsewhere.
|
||||
*/
|
||||
export function resolveSecretBackend(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SecretBackend {
|
||||
const raw = env.MULTI_SIMADMIN_SECRET_BACKEND;
|
||||
if (raw !== undefined) {
|
||||
if (!BACKENDS.includes(raw as SecretBackend))
|
||||
throw new SecretBackendError(
|
||||
'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file',
|
||||
);
|
||||
return raw as SecretBackend;
|
||||
}
|
||||
return platform === 'darwin' ? 'macos-keychain' : 'secret-file';
|
||||
}
|
||||
|
||||
export interface CreateDefaultSecretStoreOptions {
|
||||
readonly env?: Readonly<Record<string, string | undefined>>;
|
||||
/** Absolute path of the file store map; required for the secret-file backend. */
|
||||
readonly secretFilePath: string;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const backend = resolveSecretBackend(options.env, platform);
|
||||
if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath);
|
||||
if (platform !== 'darwin')
|
||||
throw new SecretBackendError(
|
||||
'The macos-keychain backend requires /usr/bin/security (macOS only)',
|
||||
);
|
||||
return new MacOSKeychainSecretStore();
|
||||
}
|
||||
|
||||
interface SecretFileMetadata {
|
||||
isFile(): boolean;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness metadata probe for the file backend. Availability only — this must
|
||||
* never read secret material.
|
||||
*/
|
||||
export async function defaultSecretFileMetadataCheck(
|
||||
filePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
statFn: (path: string) => Promise<SecretFileMetadata> = stat,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const info = await statFn(filePath);
|
||||
if (!info.isFile()) return false;
|
||||
// Windows keeps no POSIX mode bits; NTFS ACLs govern access there.
|
||||
if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A missing map is healthy: secrets are added lazily by instance setup.
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SecretKey } from './secret-store.js';
|
||||
|
||||
export const SECRET_SERVICE = 'multi-simadmin';
|
||||
|
||||
export type SecretProvider = 'macos-keychain' | 'secret-file';
|
||||
|
||||
export const SECRET_PROVIDERS: readonly SecretProvider[] = ['macos-keychain', 'secret-file'];
|
||||
|
||||
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type SecretReferenceErrorCode = 'INVALID_KEY' | 'INVALID_REFERENCE';
|
||||
|
||||
export function parseSecretAccount(
|
||||
account: string,
|
||||
provider: SecretProvider,
|
||||
): ParsedSecretReference {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
}
|
||||
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
const [instanceId, purpose, slot] = decoded;
|
||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||
validateKey(key);
|
||||
if (accountFor(key) !== account)
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
return {
|
||||
provider,
|
||||
instanceId: key.instanceId,
|
||||
purpose: key.purpose,
|
||||
...(key.slot === undefined ? {} : { slot: key.slot }),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateKey(key: SecretKey): void {
|
||||
if (
|
||||
typeof key.instanceId !== 'string' ||
|
||||
key.instanceId.length === 0 ||
|
||||
key.instanceId.length > 128 ||
|
||||
!safeComponent.test(key.instanceId)
|
||||
) {
|
||||
throw new SecretReferenceError('INVALID_KEY', 'Secret key instance id is invalid');
|
||||
}
|
||||
if (
|
||||
typeof key.purpose !== 'string' ||
|
||||
key.purpose.length === 0 ||
|
||||
key.purpose.length > 64 ||
|
||||
!safeComponent.test(key.purpose)
|
||||
) {
|
||||
throw new SecretReferenceError('INVALID_KEY', 'Secret key purpose is invalid');
|
||||
}
|
||||
if (
|
||||
key.slot !== undefined &&
|
||||
(typeof key.slot !== 'string' ||
|
||||
key.slot.length === 0 ||
|
||||
key.slot.length > 64 ||
|
||||
!safeComponent.test(key.slot))
|
||||
) {
|
||||
throw new SecretReferenceError('INVALID_KEY', 'Secret key slot is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function accountFor(key: SecretKey): string {
|
||||
validateKey(key);
|
||||
const components =
|
||||
key.slot === undefined
|
||||
? [key.instanceId, key.purpose]
|
||||
: [key.instanceId, key.purpose, key.slot];
|
||||
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export interface ParsedSecretReference {
|
||||
readonly provider: SecretProvider;
|
||||
readonly instanceId: string;
|
||||
readonly purpose: string;
|
||||
readonly slot?: string;
|
||||
}
|
||||
|
||||
export class SecretReferenceError extends Error {
|
||||
constructor(
|
||||
readonly code: SecretReferenceErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SecretReferenceError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts `keychain://multi-simadmin/<account>` and `secret-file://multi-simadmin/<account>`. */
|
||||
export function parseSecretReference(reference: string): ParsedSecretReference {
|
||||
if (typeof reference !== 'string' || reference.length > 512)
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
const match = /^([a-z-]+):\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1] || !match[2])
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
const scheme = match[1];
|
||||
const provider =
|
||||
scheme === 'keychain' ? 'macos-keychain' : scheme === 'secret-file' ? 'secret-file' : undefined;
|
||||
if (!provider) throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
return parseSecretAccount(match[2], provider);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export interface SecretKey {
|
||||
}
|
||||
|
||||
export interface SecretStore {
|
||||
/** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */
|
||||
readonly provider: string;
|
||||
set(key: SecretKey, value: string): Promise<string>;
|
||||
get(reference: string): Promise<string | undefined>;
|
||||
delete(reference: string): Promise<boolean>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret
|
||||
import { registerAutomationRoutes } from './automation-routes.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
const reference = `memory://${key.instanceId}/${key.purpose}`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerCentralNotificationRoutes } from './central-notification-routes.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
async set(key: { instanceId: string; purpose: string }, value: string) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -33,7 +33,8 @@ const cookie = (token: string, secure: boolean): string =>
|
||||
`${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
|
||||
const clearCookie = (secure: boolean): string =>
|
||||
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`;
|
||||
const usesSecureCookie = (request: FastifyRequest): boolean => request.protocol === 'https';
|
||||
const usesSecureCookie = (request: FastifyRequest): boolean =>
|
||||
request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https';
|
||||
|
||||
const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
|
||||
type: 'about:blank',
|
||||
@@ -83,8 +84,26 @@ const loginSchema = {
|
||||
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
|
||||
} as const;
|
||||
|
||||
const LOGIN_BUCKETS_MAX = 10_000;
|
||||
|
||||
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const rememberAttempt = (key: string, attempt: { count: number; resetAt: number }): void => {
|
||||
// Direct-to-API callers can send arbitrary client-ip values; cap the map so
|
||||
// bucket keys cannot grow without bound.
|
||||
if (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
|
||||
const now = Date.now();
|
||||
for (const [existing, value] of loginAttempts) {
|
||||
if (value.resetAt <= now) loginAttempts.delete(existing);
|
||||
}
|
||||
while (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
|
||||
const oldest = loginAttempts.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
loginAttempts.delete(oldest);
|
||||
}
|
||||
}
|
||||
loginAttempts.set(key, attempt);
|
||||
};
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
|
||||
if (!auth.isProtectedPath(pathname)) return;
|
||||
@@ -121,7 +140,7 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
|
||||
attempts && attempts.resetAt > now
|
||||
? attempts
|
||||
: { count: 0, resetAt: now + LOGIN_WINDOW_MS };
|
||||
loginAttempts.set(key, { count: active.count + 1, resetAt: active.resetAt });
|
||||
rememberAttempt(key, { count: active.count + 1, resetAt: active.resetAt });
|
||||
return handle(request, reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -21,6 +22,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerInstanceRoutes } from './instance-routes.js';
|
||||
|
||||
class FakeStore implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
||||
@@ -77,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();
|
||||
@@ -201,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({
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,7 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
|
||||
databasePath: join(directory, 'db.sqlite'),
|
||||
gatewayToken,
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => undefined,
|
||||
delete: async () => false,
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
} from './application/system/console-update-service.js';
|
||||
import { openDatabase } from './infrastructure/database/database.js';
|
||||
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
||||
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
||||
import {
|
||||
createDefaultSecretStore,
|
||||
resolveSecretBackend,
|
||||
defaultSecretFileMetadataCheck,
|
||||
} from './infrastructure/secrets/secret-backend.js';
|
||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
|
||||
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
|
||||
@@ -21,6 +25,7 @@ export interface ProductionControlPlaneOptions {
|
||||
readonly upstream?: SafeControlPlaneUpstream;
|
||||
readonly gatewayToken?: string;
|
||||
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
||||
readonly runtimeVersion?: string;
|
||||
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
||||
@@ -44,8 +49,10 @@ export function buildProductionControlPlane(
|
||||
const db = openDatabase(options.databasePath);
|
||||
try {
|
||||
migrateDatabase(db);
|
||||
const store = options.store ?? new MacOSKeychainSecretStore();
|
||||
const env = options.env ?? process.env;
|
||||
const secretFilePath = join(dirname(options.databasePath), 'secrets.json');
|
||||
const store = options.store ?? createDefaultSecretStore({ env, secretFilePath });
|
||||
const secretBackend = resolveSecretBackend(env);
|
||||
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
|
||||
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
|
||||
const app = buildControlPlaneApp({
|
||||
@@ -71,7 +78,16 @@ export function buildProductionControlPlane(
|
||||
readiness: createProductionReadiness({
|
||||
db,
|
||||
gatewayToken: options.gatewayToken ?? '',
|
||||
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
|
||||
...(secretBackend === 'macos-keychain'
|
||||
? {
|
||||
keychainMetadataCheck:
|
||||
options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
|
||||
}
|
||||
: {
|
||||
secretFileMetadataCheck:
|
||||
options.secretFileMetadataCheck ??
|
||||
(async () => defaultSecretFileMetadataCheck(secretFilePath)),
|
||||
}),
|
||||
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
|
||||
...(options.recoveryMarkersCheck
|
||||
? { recoveryMarkersCheck: options.recoveryMarkersCheck }
|
||||
|
||||
@@ -45,9 +45,9 @@ async function fixture(name = 'cutover'): Promise<CutoverPlan> {
|
||||
drillEvidence: drill,
|
||||
drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'),
|
||||
legacyPid: 41,
|
||||
legacyCommand: '/usr/local/bin/node /absolute/legacy/server.js',
|
||||
legacyCommand: `${process.execPath} /absolute/legacy/server.js`,
|
||||
legacyIdentity: 'legacy-start-1',
|
||||
legacyStart: ['/usr/local/bin/node', '/absolute/legacy/server.js'],
|
||||
legacyStart: [process.execPath, '/absolute/legacy/server.js'],
|
||||
stateDir: join(root, 'state'),
|
||||
};
|
||||
}
|
||||
@@ -130,11 +130,11 @@ describe('cutover validation and reversible orchestration', () => {
|
||||
expect(signals).toEqual([41]);
|
||||
expect(starts[0]?.environment?.MULTI_SIMADMIN_GATEWAY_TOKEN).toBe(token);
|
||||
const statePath = join(plan.stateDir, 'cutover-state.json');
|
||||
expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== 'win32') expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(statePath, 'utf8')).not.toContain(token);
|
||||
expect(await rollback(plan, system)).toBe(43);
|
||||
expect(signals).toEqual([41, 42]);
|
||||
expect(starts[1]?.argv).toEqual(['/usr/local/bin/node', '/absolute/legacy/server.js']);
|
||||
expect(starts[1]?.argv).toEqual([process.execPath, '/absolute/legacy/server.js']);
|
||||
});
|
||||
|
||||
it('automatically restarts legacy if the gateway fails to own authenticated 8788', async () => {
|
||||
@@ -146,10 +146,10 @@ describe('cutover validation and reversible orchestration', () => {
|
||||
[
|
||||
process.execPath,
|
||||
'--import',
|
||||
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
|
||||
'tsx',
|
||||
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
|
||||
],
|
||||
['/usr/local/bin/node', '/absolute/legacy/server.js'],
|
||||
[process.execPath, '/absolute/legacy/server.js'],
|
||||
]);
|
||||
const state = JSON.parse(await readFile(join(plan.stateDir, 'cutover-state.json'), 'utf8')) as {
|
||||
phase: string;
|
||||
@@ -176,11 +176,13 @@ describe('cutover validation and reversible orchestration', () => {
|
||||
const first = fakeSystem(plan);
|
||||
await cutover(plan, first.system, token);
|
||||
const statePath = join(plan.stateDir, 'cutover-state.json');
|
||||
await chmod(statePath, 0o644);
|
||||
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
|
||||
expect(first.signals).toEqual([41]);
|
||||
|
||||
await chmod(statePath, 0o600);
|
||||
// Windows chmod is a no-op, so the mode-0600 refusal only applies on POSIX.
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(statePath, 0o644);
|
||||
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
|
||||
expect(first.signals).toEqual([41]);
|
||||
await chmod(statePath, 0o600);
|
||||
}
|
||||
const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>;
|
||||
await writeFile(
|
||||
statePath,
|
||||
|
||||
@@ -7,7 +7,9 @@ export interface ProductionReadinessOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly gatewayToken: string;
|
||||
/** Metadata/availability probe only. This callback must never retrieve a secret. */
|
||||
readonly keychainMetadataCheck: () => boolean | Promise<boolean>;
|
||||
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
/** Metadata/availability probe for the file secret backend (never reads secrets). */
|
||||
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
/** Release gates are deliberately required: omitting one fails readiness closed. */
|
||||
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
||||
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
||||
@@ -56,11 +58,20 @@ export function createProductionReadiness(
|
||||
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
|
||||
if (checks.gatewayAuth !== 'ready') ready = false;
|
||||
try {
|
||||
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
||||
if (options.keychainMetadataCheck !== undefined) {
|
||||
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
||||
if (checks.keychain !== 'ready') ready = false;
|
||||
} else if (options.secretFileMetadataCheck !== undefined) {
|
||||
checks.secretFile = (await options.secretFileMetadataCheck()) ? 'ready' : 'unavailable';
|
||||
if (checks.secretFile !== 'ready') ready = false;
|
||||
} else {
|
||||
checks.secrets = 'unconfigured';
|
||||
ready = false;
|
||||
}
|
||||
} catch {
|
||||
checks.keychain = 'unavailable';
|
||||
checks.secrets = 'unavailable';
|
||||
ready = false;
|
||||
}
|
||||
if (checks.keychain !== 'ready') ready = false;
|
||||
|
||||
try {
|
||||
const row = options.db
|
||||
|
||||
@@ -254,11 +254,14 @@ export async function appendReleaseEvidence(
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, absolute);
|
||||
const directory = await open(parent, 'r');
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
if (process.platform !== 'win32') {
|
||||
// directory fsync only exists on POSIX
|
||||
const directory = await open(parent, 'r');
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
return Object.freeze(record);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -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 |
@@ -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)
|
||||
@@ -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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 970 KiB |
+14
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user