@adep/cli 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -11
- package/dist/index.js +33802 -27354
- package/dist/vite.js +247 -17
- package/dist/worker-entry.js +163 -1
- package/package.json +7 -6
- package/templates/app-desktop/README.md +67 -2
- package/templates/app-desktop/identity-store.mjs +309 -0
- package/templates/app-desktop/identity.mjs +517 -0
- package/templates/app-desktop/main.cjs +17 -0
- package/templates/app-desktop/main.mjs +218 -2
- package/templates/app-desktop/offline-runtime.mjs +72 -3
- package/templates/app-desktop/package.json +10 -6
- package/templates/app-desktop/recovery-ui.mjs +300 -0
- package/templates/app-desktop/sync-loop.mjs +137 -0
- package/templates/app-mobile/README.md +47 -9
- package/templates/app-mobile/package.json +2 -1
- package/templates/app-mobile/plugins/adep-bridge/Package.swift +5 -1
- package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +34 -3
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +12 -5
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +198 -12
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepIdentity.kt +342 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +56 -2
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt +506 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqliteLogic.kt +235 -0
- package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +30 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +3 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +221 -9
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepIdentity.swift +500 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +69 -17
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +25 -4
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepShellConfig.swift +54 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepSqlite.swift +614 -0
- package/templates/app-mobile/plugins/adep-bridge/package.json +10 -0
- package/templates/app-mobile/template.json +12 -3
- package/templates/app-mobile/web/adep-bridge.js +103 -9
- package/templates/app-mobile/web/offline-runtime.js +230 -0
- package/templates/app-mobile-xtool/AdepShell-Info.plist +24 -0
- package/templates/app-mobile-xtool/Package.swift +59 -0
- package/templates/app-mobile-xtool/README.md +81 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/App.swift +36 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/ShellViewController.swift +223 -0
- package/templates/app-mobile-xtool/adep.permissions.json +4 -0
- package/templates/app-mobile-xtool/app/site/.gitkeep +0 -0
- package/templates/app-mobile-xtool/template.json +15 -0
- package/templates/app-mobile-xtool/xtool.yml +35 -0
- package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +0 -10
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌面壳设备身份自举(APP-033;PRD §2.11.5「设备身份与恢复」/ ADR-0008 第 2 条)。
|
|
3
|
+
*
|
|
4
|
+
* ## 这一层解决什么问题
|
|
5
|
+
*
|
|
6
|
+
* `ownerKey` 必须由**服务端**签发(本地自造 / 构建期预置都判违规)。移动壳在 APP-027
|
|
7
|
+
* 已经有完整自举(原生侧首启签发 → Keychain / Keystore),而桌面壳**一次都没调过签发端点**:
|
|
8
|
+
* `main.mjs` 不读不存、`preload.cjs` 也不注入。后果是 APP-032 的同步 transport 只能在开发机
|
|
9
|
+
* 上靠 `adep app sync --owner-key` 手传——装机到用户机器上「功能齐了,钥匙没有」。
|
|
10
|
+
*
|
|
11
|
+
* ## 三条刻意的边界(与移动壳同一条口径)
|
|
12
|
+
*
|
|
13
|
+
* 1. **不做在页面侧**:`preload.cjs` 不暴露 ownerKey,页面拿不到这枚 bearer 凭据。
|
|
14
|
+
* 同步跑在主进程(transport 在 `offline-runtime.mjs` 里装配),页面不需要它;
|
|
15
|
+
* 反之,把凭据交给渲染进程等于「页面里任何一个 XSS 都能劫持该设备的数据分区」。
|
|
16
|
+
* 身份**状态**(backend / 是否已签发)由 `adep app doctor` 读 `data/identity/status.json`
|
|
17
|
+
* 呈现——那是既有的体检读数面,不新开一条 IPC 通道。
|
|
18
|
+
* 2. **失败不改变可用性**:断网 / 5xx / 响应形态不符 ⇒ 不写空值、不覆盖既有值,只留带
|
|
19
|
+
* **原因码**的日志与状态文件,下次启动重试。首启失败不得阻止 app 启动。
|
|
20
|
+
* 3. **幂等**:本地已有合法 ownerKey ⇒ 不发请求(一枚安装只签发一次)。
|
|
21
|
+
*
|
|
22
|
+
* ## 为什么这个文件不 import electron
|
|
23
|
+
*
|
|
24
|
+
* 判定(配置 / URL / 形态 / 响应解析 / 平台映射)与副作用(存储、传输、日志)全部拆开,
|
|
25
|
+
* 判定层是纯函数 ⇒ `app-desktop-identity.test.ts` 能把**被测源码原样** import 进来真跑,
|
|
26
|
+
* 不做镜像实现(APP-027 用 swiftc/kotlinc 编原文件,这里是同一思路的 Node 版)。
|
|
27
|
+
* `main.mjs` 才是唯一碰 electron 的地方。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 自举配置(`adep.app.json` 的投影;完整校验归 CLI 侧,这里只取两个字段)。
|
|
32
|
+
*
|
|
33
|
+
* @typedef {Object} adepIdentityConfig
|
|
34
|
+
* @property {string} platformUrl 平台地址(http(s) 绝对地址)
|
|
35
|
+
* @property {string} projectSlug 项目 slug(会拼进签发端点路径)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 签发响应的投影(三个字段都通过形态校验后才存在这个类型里)。
|
|
40
|
+
*
|
|
41
|
+
* @typedef {Object} adepIssueResult
|
|
42
|
+
* @property {string} ownerKey 设备凭据(`adep_ok_` + 64 位小写 hex)
|
|
43
|
+
* @property {string} installationId 安装行主键
|
|
44
|
+
* @property {string} recoveryCode 一次性恢复码(明文只在签发响应里出现一次)
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 自举依赖(全部注入:存储 / 配置 / 传输 / 日志 / 状态落盘)。
|
|
49
|
+
*
|
|
50
|
+
* @typedef {Object} adepBootstrapDependencies
|
|
51
|
+
* @property {() => unknown} readConfig 读壳根 adep.app.json(返回 JSON.parse 结果)
|
|
52
|
+
* @property {adepProtectedStore} store 受保护存储
|
|
53
|
+
* @property {(url: string, body: string, timeoutMs: number) => Promise<{status: number, body: string | null, error: string | null}>} transport 签发传输
|
|
54
|
+
* @property {string} platform 服务端平台枚举值(APP_PLATFORMS 之一,没有 desktop)
|
|
55
|
+
* @property {number} [timeoutMs] 签发请求超时(缺省 10s)
|
|
56
|
+
* @property {(line: string) => void} [log] 日志(每条带原因码)
|
|
57
|
+
* @property {(status: Record<string, unknown>) => void | Promise<void>} [recordStatus] 状态落盘(非机密读数面)
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 受保护存储面(identity-store.mjs 的返回形态;测试用假实现同样满足)。
|
|
62
|
+
*
|
|
63
|
+
* @typedef {Object} adepProtectedStore
|
|
64
|
+
* @property {string} backend 实际生效的后端(退化后是 file)
|
|
65
|
+
* @property {() => Promise<string|null>} readOwnerKey
|
|
66
|
+
* @property {(value: string) => Promise<void>} writeOwnerKey
|
|
67
|
+
* @property {() => Promise<string|null>} readRecoveryCode
|
|
68
|
+
* @property {(value: string) => Promise<void>} writeRecoveryCode
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** 自举结果码。日志与状态文件里必须出现码本身(排障时先看码,再看文案)。 */
|
|
72
|
+
export const ADEP_IDENTITY_CODES = {
|
|
73
|
+
/** 壳根没有 `adep.app.json`(未 init 或文件被删)。 */
|
|
74
|
+
configMissing: 'IDENTITY_CONFIG_MISSING',
|
|
75
|
+
/** 配置在但字段非法(platformUrl 不是 http(s) / projectSlug 形态不符)。 */
|
|
76
|
+
configInvalid: 'IDENTITY_CONFIG_INVALID',
|
|
77
|
+
/** 传输层失败或非 2xx(断网 / 超时 / 5xx / 429)。 */
|
|
78
|
+
requestFailed: 'IDENTITY_REQUEST_FAILED',
|
|
79
|
+
/** 2xx 但响应形态不符(不是 JSON / 缺 installation / ownerKey 形态不对)。 */
|
|
80
|
+
responseInvalid: 'IDENTITY_RESPONSE_INVALID',
|
|
81
|
+
/** 拿到合法凭据但落受保护存储失败(含写后读回不一致)。 */
|
|
82
|
+
storeFailed: 'IDENTITY_STORE_FAILED',
|
|
83
|
+
/** 已签发(跳过,未发请求)。 */
|
|
84
|
+
alreadyIssued: 'IDENTITY_ALREADY_ISSUED',
|
|
85
|
+
/** 同一实例重复 start(跳过,未发请求)。 */
|
|
86
|
+
alreadyStarted: 'IDENTITY_ALREADY_STARTED',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ownerKey 明文前缀(与 `server/domains/appbuild/identity/contract.ts` 的 `OWNER_KEY_PREFIX` 逐字对应)。 */
|
|
90
|
+
export const OWNER_KEY_PREFIX = 'adep_ok_'
|
|
91
|
+
/** ownerKey 随机段 hex 长度(服务端 `OWNER_KEY_BYTES = 32` ⇒ 64 位 hex,256 位熵)。 */
|
|
92
|
+
export const OWNER_KEY_HEX_LENGTH = 64
|
|
93
|
+
/**
|
|
94
|
+
* 签发端点路径(服务端 `sync-routes.ts` 的 `/v1/projects/:projectId/app-installations`,
|
|
95
|
+
* 平台侧挂在 `/api` 前缀下;`:projectId` 接受 **id 或 slug**)。
|
|
96
|
+
*/
|
|
97
|
+
export const ISSUE_PATH_PREFIX = '/api/v1/projects/'
|
|
98
|
+
export const ISSUE_PATH_SUFFIX = '/app-installations'
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 宿主 OS → 服务端 `APP_PLATFORMS`(`server/domains/appbuild/manifest/schema.ts`)。
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ 这里**没有** `desktop` 这个取值——APP-032 的 e2e 就栽在照抄 CLI 的
|
|
104
|
+
* `--platform desktop|mobile` 上(那是「哪种壳」,不是「哪个系统」),签发端直接
|
|
105
|
+
* `400 APP_INSTALL_INPUT_INVALID`。两套枚举同名不同域,别互相代入。
|
|
106
|
+
*/
|
|
107
|
+
const HOST_PLATFORM = { darwin: 'macos', win32: 'windows' }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 宿主 OS → 服务端平台枚举。
|
|
111
|
+
*
|
|
112
|
+
* @param {string} hostPlatform `process.platform` 的取值
|
|
113
|
+
* @returns {'macos'|'windows'|'linux'}
|
|
114
|
+
*/
|
|
115
|
+
export function platformForHost(hostPlatform) {
|
|
116
|
+
return HOST_PLATFORM[hostPlatform] ?? 'linux'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ownerKey 形态:前缀 + 64 位**小写** hex(与服务端正则完全同形)。
|
|
121
|
+
*
|
|
122
|
+
* 刻意严格(不宽松地接受大写):宽松会让「服务端换了编码」这类契约漂移在客户端静默通过,
|
|
123
|
+
* 然后在同步侧 401——错误在最远的地方爆。
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} value
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
export function isOwnerKey(value) {
|
|
129
|
+
if (typeof value !== 'string' || !value.startsWith(OWNER_KEY_PREFIX)) return false
|
|
130
|
+
const hex = value.slice(OWNER_KEY_PREFIX.length)
|
|
131
|
+
if (hex.length !== OWNER_KEY_HEX_LENGTH) return false
|
|
132
|
+
return /^[0-9a-f]+$/.test(hex)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* projectSlug 形态(与 `adep.app.json` schema 的 `^[a-z][a-z0-9-]{0,62}$` 同形)。
|
|
137
|
+
*
|
|
138
|
+
* 在客户端再判一次的理由:拼 URL 时 slug 会成为**路径段**,形态失守就是拼出一个别的端点。
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} value
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
export function isProjectSlug(value) {
|
|
144
|
+
return typeof value === 'string' && /^[a-z][a-z0-9-]{0,62}$/.test(value)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 身份层错误(带原因码;`message` 会被写进日志与状态文件,必须能直接排障)。 */
|
|
148
|
+
export class IdentityError extends Error {
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} code
|
|
151
|
+
* @param {string} message
|
|
152
|
+
*/
|
|
153
|
+
constructor(code, message) {
|
|
154
|
+
super(message)
|
|
155
|
+
this.name = 'IdentityError'
|
|
156
|
+
this.code = code
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 解析壳根 `adep.app.json` 字节里的自举配置。
|
|
162
|
+
*
|
|
163
|
+
* 只取自举需要的两个字段,其余不校验:壳配置的完整校验在 CLI 侧 `app-config.ts`,
|
|
164
|
+
* 客户端再抄一份 schema 只会漂移。
|
|
165
|
+
*
|
|
166
|
+
* @param {unknown} raw 已经 JSON.parse 过的对象
|
|
167
|
+
* @returns {adepIdentityConfig} 自举所需的最小字段集
|
|
168
|
+
*/
|
|
169
|
+
export function parseIdentityConfig(raw) {
|
|
170
|
+
if (raw === null || typeof raw !== 'object') {
|
|
171
|
+
throw new IdentityError(ADEP_IDENTITY_CODES.configInvalid, 'adep.app.json 不是 JSON 对象')
|
|
172
|
+
}
|
|
173
|
+
const platformUrl = /** @type {Record<string, unknown>} */ (raw).platformUrl
|
|
174
|
+
if (typeof platformUrl !== 'string') {
|
|
175
|
+
throw new IdentityError(ADEP_IDENTITY_CODES.configInvalid, 'adep.app.json 缺 platformUrl')
|
|
176
|
+
}
|
|
177
|
+
/** @type {URL} */
|
|
178
|
+
let url
|
|
179
|
+
try {
|
|
180
|
+
url = new URL(platformUrl)
|
|
181
|
+
} catch {
|
|
182
|
+
throw new IdentityError(
|
|
183
|
+
ADEP_IDENTITY_CODES.configInvalid,
|
|
184
|
+
`platformUrl 不是合法 URL:${platformUrl}`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hostname === '') {
|
|
188
|
+
throw new IdentityError(
|
|
189
|
+
ADEP_IDENTITY_CODES.configInvalid,
|
|
190
|
+
`platformUrl 必须是 http(s) 绝对地址:${platformUrl}`
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
const projectSlug = /** @type {Record<string, unknown>} */ (raw).projectSlug
|
|
194
|
+
if (!isProjectSlug(projectSlug)) {
|
|
195
|
+
throw new IdentityError(
|
|
196
|
+
ADEP_IDENTITY_CODES.configInvalid,
|
|
197
|
+
`projectSlug 形态不符(需小写字母开头,仅小写字母/数字/连字符):${String(projectSlug)}`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
return { platformUrl, projectSlug }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 组装签发端点 URL。`platformUrl` 常带尾斜杠(`https://adep.top/`)⇒ 先归一,
|
|
205
|
+
* 否则拼出 `//api/v1/...`(服务端 404,症状像「端点没部署」)。
|
|
206
|
+
*
|
|
207
|
+
* @param {adepIdentityConfig} config
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
export function issueUrl(config) {
|
|
211
|
+
const url = new URL(config.platformUrl)
|
|
212
|
+
let base = url.pathname
|
|
213
|
+
while (base.endsWith('/')) base = base.slice(0, -1)
|
|
214
|
+
url.pathname = `${base}${ISSUE_PATH_PREFIX}${config.projectSlug}${ISSUE_PATH_SUFFIX}`
|
|
215
|
+
// 平台地址上的 query / fragment 对端点无意义,带上只会污染请求。
|
|
216
|
+
url.search = ''
|
|
217
|
+
url.hash = ''
|
|
218
|
+
return url.toString()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 解析签发响应:`{ installation: { installationId, ownerKey, recoveryCode, partition, platform } }`。
|
|
223
|
+
*
|
|
224
|
+
* **形态不符一律抛**(不写「尽量取到的那部分」):ownerKey 是 bearer 凭据,把一个空串 /
|
|
225
|
+
* 半截值写进受保护存储,等于把「设备身份」变成「永远 401」的垃圾状态,而且**看起来像已签发**
|
|
226
|
+
* (下次启动不再重试)——比直接失败难查得多。
|
|
227
|
+
*
|
|
228
|
+
* @param {unknown} raw 已经 JSON.parse 过的响应体
|
|
229
|
+
* @returns {adepIssueResult} 三个字段都已通过形态校验
|
|
230
|
+
*/
|
|
231
|
+
export function parseIssueResponse(raw) {
|
|
232
|
+
const invalid = (/** @type {string} */ message) =>
|
|
233
|
+
new IdentityError(ADEP_IDENTITY_CODES.responseInvalid, message)
|
|
234
|
+
if (raw === null || typeof raw !== 'object') throw invalid('签发响应不是 JSON 对象')
|
|
235
|
+
const installation = /** @type {Record<string, unknown>} */ (raw).installation
|
|
236
|
+
if (installation === null || typeof installation !== 'object') {
|
|
237
|
+
throw invalid('签发响应缺 installation 对象')
|
|
238
|
+
}
|
|
239
|
+
const fields = /** @type {Record<string, unknown>} */ (installation)
|
|
240
|
+
if (!isOwnerKey(fields.ownerKey)) {
|
|
241
|
+
throw invalid(
|
|
242
|
+
`installation.ownerKey 形态不符(期望 ${OWNER_KEY_PREFIX} + ${OWNER_KEY_HEX_LENGTH} 位小写 hex)`
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
const installationId = fields.installationId
|
|
246
|
+
if (typeof installationId !== 'string' || installationId === '') {
|
|
247
|
+
throw invalid('installation.installationId 缺失或为空')
|
|
248
|
+
}
|
|
249
|
+
const recoveryCode = fields.recoveryCode
|
|
250
|
+
if (typeof recoveryCode !== 'string' || recoveryCode === '') {
|
|
251
|
+
throw invalid('installation.recoveryCode 缺失或为空')
|
|
252
|
+
}
|
|
253
|
+
return { ownerKey: fields.ownerKey, installationId, recoveryCode }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 恢复码脱敏(日志用)。恢复码形态 `<installationId>.<secret>`:**installationId 不是机密**
|
|
258
|
+
* (它就是安装行主键),secret 段才是凭据 ⇒ 保留 installationId + secret 前 4 位 + 总长度
|
|
259
|
+
* (长度能看出「码被截断」这类畸形)。
|
|
260
|
+
*
|
|
261
|
+
* @param {string} value
|
|
262
|
+
* @param {string} installationId
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
export function maskRecoveryCode(value, installationId) {
|
|
266
|
+
const head = installationId === '' ? '' : `${installationId}.`
|
|
267
|
+
const secret = value.startsWith(head) ? value.slice(head.length) : value
|
|
268
|
+
return `${head}${secret.slice(0, 4)}…(len ${value.length})`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 生产传输:`fetch` + `AbortSignal.timeout`。
|
|
273
|
+
*
|
|
274
|
+
* 返回形态与注入面一致(`status === 0` 表示**没拿到 HTTP 响应**),这样失败分支在测试里
|
|
275
|
+
* 能被同一组断言覆盖,不必分辨「真网络」与「假传输」。
|
|
276
|
+
*
|
|
277
|
+
* @param {number} timeoutMs
|
|
278
|
+
* @returns {(url: string, body: string, timeoutMs: number) => Promise<{ status: number, body: string | null, error: string | null }>}
|
|
279
|
+
*/
|
|
280
|
+
export function createFetchTransport(timeoutMs) {
|
|
281
|
+
return async (url, body) => {
|
|
282
|
+
try {
|
|
283
|
+
const response = await fetch(url, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
286
|
+
body,
|
|
287
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
288
|
+
})
|
|
289
|
+
return { status: response.status, body: await response.text(), error: null }
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return {
|
|
292
|
+
status: 0,
|
|
293
|
+
body: null,
|
|
294
|
+
error: error instanceof Error ? error.message : String(error),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 首启自举:查本地 →(缺则)签发 → 落受保护存储 → 通知调用方。
|
|
302
|
+
*
|
|
303
|
+
* 依赖全部注入(存储 / 配置 / 传输 / 日志 / 状态落盘)——「要不要请求」「写不写」「写什么」
|
|
304
|
+
* 三条判定都能真跑,而不是靠读源码猜。
|
|
305
|
+
*
|
|
306
|
+
* 顺序即不变量:**本地有 ownerKey ⇒ 本地必有与它同源的恢复码**。所以先写恢复码并读回校验,
|
|
307
|
+
* 再写 ownerKey。反过来会出现「app 已带着这枚身份跑,用户却永远拿不到恢复码」——明文只在
|
|
308
|
+
* 这一次响应里存在(服务端只存摘要,任何端点都不重发),丢了就是该分区永久不可认领。
|
|
309
|
+
*/
|
|
310
|
+
export class IdentityBootstrap {
|
|
311
|
+
/**
|
|
312
|
+
* @param {adepBootstrapDependencies} dependencies 全部注入(判定与副作用分离的关键)
|
|
313
|
+
*/
|
|
314
|
+
constructor(dependencies) {
|
|
315
|
+
this.dependencies = dependencies
|
|
316
|
+
this.started = false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 启动一次自举。**幂等**:同一实例重复调用只发一次请求。
|
|
321
|
+
*
|
|
322
|
+
* 永不 throw——失败一律折成 `{ status: 'failed', code, detail }`,调用方(主进程)
|
|
323
|
+
* 不得因为身份没签到而阻止窗口创建。
|
|
324
|
+
*
|
|
325
|
+
* @returns {Promise<{ status: 'issued'|'skipped'|'failed', code: string, ownerKey?: string, recoveryCode?: string, backend: string, installationId?: string, detail?: string }>}
|
|
326
|
+
*/
|
|
327
|
+
async start() {
|
|
328
|
+
const { log = () => {}, store } = this.dependencies
|
|
329
|
+
if (this.started) {
|
|
330
|
+
log(`[adep-identity] ${ADEP_IDENTITY_CODES.alreadyStarted}: 已启动过,忽略重复调用`)
|
|
331
|
+
return this.#skip(ADEP_IDENTITY_CODES.alreadyStarted)
|
|
332
|
+
}
|
|
333
|
+
this.started = true
|
|
334
|
+
|
|
335
|
+
// ① 已有合法 ownerKey ⇒ 一枚安装只签发一次(分母断言:这条路径下请求数必须为 0)。
|
|
336
|
+
const existing = await store.readOwnerKey()
|
|
337
|
+
if (typeof existing === 'string' && existing !== '') {
|
|
338
|
+
if (isOwnerKey(existing)) {
|
|
339
|
+
const masked = `${existing.slice(0, OWNER_KEY_PREFIX.length + 6)}…`
|
|
340
|
+
log(`[adep-identity] ${ADEP_IDENTITY_CODES.alreadyIssued}: 本地已有 ownerKey(${masked}),不重复签发`)
|
|
341
|
+
return this.#skip(ADEP_IDENTITY_CODES.alreadyIssued, existing)
|
|
342
|
+
}
|
|
343
|
+
// 形态不符(历史垃圾值 / 手写调试值)不是可用身份 ⇒ 重新签发覆盖它。
|
|
344
|
+
// 不静默当成「已签发」——那会让 app 永远带着一枚服务端不认的凭据。
|
|
345
|
+
log(`[adep-identity] 本地 ownerKey 形态不符(长度 ${existing.length}),将重新签发`)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ② 配置(壳根 adep.app.json)。缺失与非法分开报:前者是打包问题,后者是配置写错。
|
|
349
|
+
/** @type {adepIdentityConfig} */
|
|
350
|
+
let config
|
|
351
|
+
try {
|
|
352
|
+
config = parseIdentityConfig(this.dependencies.readConfig())
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return this.#fail(error)
|
|
355
|
+
}
|
|
356
|
+
let url = ''
|
|
357
|
+
try {
|
|
358
|
+
url = issueUrl(config)
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return this.#fail(error)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ③ 请求。非 2xx 与传输层失败都落 requestFailed,但 detail 区分(status 0 = 没拿到响应)。
|
|
364
|
+
const body = JSON.stringify({ platform: this.dependencies.platform })
|
|
365
|
+
const result = await this.dependencies.transport(url, body, this.dependencies.timeoutMs ?? 10_000)
|
|
366
|
+
if (result.status < 200 || result.status >= 300) {
|
|
367
|
+
const detail =
|
|
368
|
+
result.error === null
|
|
369
|
+
? `HTTP ${result.status}(url: ${url};响应:${(result.body ?? '').slice(0, 200)})`
|
|
370
|
+
: `传输层失败:${result.error}(url: ${url})`
|
|
371
|
+
return this.#fail(new IdentityError(ADEP_IDENTITY_CODES.requestFailed, detail))
|
|
372
|
+
}
|
|
373
|
+
if (result.body === null || result.body === '') {
|
|
374
|
+
return this.#fail(
|
|
375
|
+
new IdentityError(ADEP_IDENTITY_CODES.responseInvalid, `HTTP ${result.status} 但响应体为空`)
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ④ 解析 + 落库。
|
|
380
|
+
/** @type {adepIssueResult} */
|
|
381
|
+
let issue
|
|
382
|
+
try {
|
|
383
|
+
/** @type {unknown} */
|
|
384
|
+
let parsed
|
|
385
|
+
try {
|
|
386
|
+
parsed = JSON.parse(result.body)
|
|
387
|
+
} catch {
|
|
388
|
+
throw new IdentityError(ADEP_IDENTITY_CODES.responseInvalid, '签发响应不是合法 JSON')
|
|
389
|
+
}
|
|
390
|
+
issue = parseIssueResponse(parsed)
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return this.#fail(error)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ⑤ 恢复码先落库并读回校验(APP-028 的顺序)。
|
|
396
|
+
try {
|
|
397
|
+
await store.writeRecoveryCode(issue.recoveryCode)
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return this.#fail(
|
|
400
|
+
new IdentityError(
|
|
401
|
+
ADEP_IDENTITY_CODES.storeFailed,
|
|
402
|
+
`恢复码写入受保护存储失败(installationId: ${issue.installationId}):${describe(error)}——本次不落 ownerKey,下次启动重新签发`
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
// 读回校验:写接口报成功但读不出是真实故障形态(条目被策略清掉 / backend 回退写到了
|
|
407
|
+
// 另一处)。只看写返回成功会把「码已丢」当成功——正是 APP-028 要消灭的静默。
|
|
408
|
+
if ((await store.readRecoveryCode()) !== issue.recoveryCode) {
|
|
409
|
+
return this.#fail(
|
|
410
|
+
new IdentityError(
|
|
411
|
+
ADEP_IDENTITY_CODES.storeFailed,
|
|
412
|
+
`恢复码写入后读回不一致(installationId: ${issue.installationId})——本次不落 ownerKey`
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ⑥ ownerKey 落库(写不进去也算失败:下次启动还会再签发一次,多一枚孤儿安装行)。
|
|
418
|
+
try {
|
|
419
|
+
await store.writeOwnerKey(issue.ownerKey)
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return this.#fail(
|
|
422
|
+
new IdentityError(
|
|
423
|
+
ADEP_IDENTITY_CODES.storeFailed,
|
|
424
|
+
`ownerKey 写入受保护存储失败(installationId: ${issue.installationId}):${describe(error)}`
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
log(
|
|
430
|
+
`[adep-identity] 签发成功(installationId: ${issue.installationId}, platform: ${this.dependencies.platform}, ` +
|
|
431
|
+
`storage: ${store.backend}, recoveryCode: ${maskRecoveryCode(issue.recoveryCode, issue.installationId)})`
|
|
432
|
+
)
|
|
433
|
+
await this.#record({
|
|
434
|
+
state: 'issued',
|
|
435
|
+
code: 'IDENTITY_ISSUED',
|
|
436
|
+
backend: store.backend,
|
|
437
|
+
installationId: issue.installationId,
|
|
438
|
+
platform: this.dependencies.platform,
|
|
439
|
+
})
|
|
440
|
+
return {
|
|
441
|
+
status: 'issued',
|
|
442
|
+
code: 'IDENTITY_ISSUED',
|
|
443
|
+
ownerKey: issue.ownerKey,
|
|
444
|
+
recoveryCode: issue.recoveryCode,
|
|
445
|
+
backend: store.backend,
|
|
446
|
+
installationId: issue.installationId,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 跳过收口(未发请求)。状态文件照写:doctor 要能区分「已签发(正常)」与「还没跑过」。
|
|
452
|
+
*
|
|
453
|
+
* @param {string} code
|
|
454
|
+
* @param {string} [ownerKey]
|
|
455
|
+
*/
|
|
456
|
+
async #skip(code, ownerKey) {
|
|
457
|
+
const { store } = this.dependencies
|
|
458
|
+
await this.#record({
|
|
459
|
+
state: 'issued',
|
|
460
|
+
code,
|
|
461
|
+
backend: store.backend,
|
|
462
|
+
platform: this.dependencies.platform,
|
|
463
|
+
})
|
|
464
|
+
return {
|
|
465
|
+
status: 'skipped',
|
|
466
|
+
code,
|
|
467
|
+
...(typeof ownerKey === 'string' ? { ownerKey } : {}),
|
|
468
|
+
backend: store.backend,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 失败收口:日志带**原因码**(排障顺序:先看码,再看详情),状态文件同步落一份。
|
|
474
|
+
* 失败时**不写任何凭据**——只写状态(state=failed + code),下次启动据此重试。
|
|
475
|
+
*
|
|
476
|
+
* @param {unknown} error
|
|
477
|
+
*/
|
|
478
|
+
async #fail(error) {
|
|
479
|
+
const { log = () => {} } = this.dependencies
|
|
480
|
+
const code = error instanceof IdentityError ? error.code : ADEP_IDENTITY_CODES.requestFailed
|
|
481
|
+
const detail = describe(error)
|
|
482
|
+
log(`[adep-identity] ${code}: ${detail}`)
|
|
483
|
+
await this.#record({ state: 'failed', code, backend: this.dependencies.store.backend, detail })
|
|
484
|
+
return {
|
|
485
|
+
status: 'failed',
|
|
486
|
+
code,
|
|
487
|
+
detail,
|
|
488
|
+
backend: this.dependencies.store.backend,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 状态落盘(非机密:只有 backend / 码 / installationId)。写失败不算自举失败——
|
|
494
|
+
* 状态文件是**读数面**,不是身份本身;丢一份读数不该让已经落库的凭据变成无效。
|
|
495
|
+
*
|
|
496
|
+
* @param {Record<string, unknown>} status
|
|
497
|
+
*/
|
|
498
|
+
async #record(status) {
|
|
499
|
+
const record = this.dependencies.recordStatus
|
|
500
|
+
if (record === undefined) return
|
|
501
|
+
try {
|
|
502
|
+
await record({ ...status, at: new Date().toISOString() })
|
|
503
|
+
} catch (error) {
|
|
504
|
+
this.dependencies.log?.(`[adep-identity] 状态文件写入失败(不影响身份):${describe(error)}`)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 可读错误文本(`Error` 取 message,其余 String 化)。
|
|
511
|
+
*
|
|
512
|
+
* @param {unknown} error
|
|
513
|
+
* @returns {string}
|
|
514
|
+
*/
|
|
515
|
+
export function describe(error) {
|
|
516
|
+
return error instanceof Error ? error.message : String(error)
|
|
517
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESM 主进程入口的 CJS 包装(APP-036)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么入口是 .cjs 而不是直接用 main.mjs
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-23 视觉验收实测(macOS 27.0.1 / Electron 44.9.3):ESM 主进程(`main: main.mjs`)
|
|
7
|
+
* 的 top-level `await app.whenReady()` **永不 resolve**——主线程停在 libuv 的 io poll 上、
|
|
8
|
+
* 无任何错误输出、窗口不出现(TRACE 打点钉死卡点;对照实验:同一二进制跑 CJS 入口,
|
|
9
|
+
* whenReady 47ms 即 resolve)。CJS 入口一切正常,故模板入口换成本包装,`main.mjs`
|
|
10
|
+
* 保持 ESM 语义不动(import 链 / top-level await 都不变,只是由本文件动态 import)。
|
|
11
|
+
*
|
|
12
|
+
* 失败响亮:动态 import 失败(模板缺文件 / 语法错)不能静默——打日志并置非零退出码。
|
|
13
|
+
*/
|
|
14
|
+
import('./main.mjs').catch((error) => {
|
|
15
|
+
console.error('[adep-app] 主进程加载失败:', error)
|
|
16
|
+
process.exitCode = 1
|
|
17
|
+
})
|