@adep/cli 0.4.1 → 0.4.5
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 +140 -11
- package/dist/index.js +34568 -27795
- package/dist/vite.js +279 -19
- package/dist/worker-entry.js +201 -2
- package/package.json +6 -5
- 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 +52 -0
- 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 +7 -1
- 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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 恢复码呈现与导出(APP-034,app-desktop 模板)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:APP-033 让桌面壳首启自举把恢复码**落库**(明文只在签发响应里出现一次,
|
|
5
|
+
* 服务端只存摘要、任何端点都不重发),但用户从没见过它——直到真丢了 ownerKey
|
|
6
|
+
* 才发现手里没有认领凭据。本模块补上「读数 → 呈现 → 导出」的最后一跳。
|
|
7
|
+
*
|
|
8
|
+
* 形态决策(为什么全在主进程、不进渲染进程):
|
|
9
|
+
* - 页面是**用户内容**(app/site/ 产物),壳不能往里注 DOM,也没有设置页可挂;
|
|
10
|
+
* - Electron 的应用菜单 + 原生对话框就是壳自己的「设置 / 关于」面,不改 preload、
|
|
11
|
+
* 不开 IPC——恢复码与 ownerKey 都**不进渲染进程**(APP-033 已立的边界不回退)。
|
|
12
|
+
*
|
|
13
|
+
* 「呈现一次」的判定:`data/identity/recovery-code-shown` 标记文件。
|
|
14
|
+
* 刻意**不**绑死「本次刚签发」:老安装升级到带本模块的版本时,用户同样一次都没见过码,
|
|
15
|
+
* 标记缺失 + 码可读 ⇒ 也呈现一次。呈现过就写标记,此后只从菜单进(不再主动弹)。
|
|
16
|
+
*
|
|
17
|
+
* JSDoc 用 `@typedef` + `@property` 形态(**别写对象字面量类型**——模板渲染门
|
|
18
|
+
* 会咬「白名单外的双花括号残留」,APP-033 实证;连注释里提这个写法都不行)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
/** 「已呈现过」标记文件名(落 data/identity/,非机密读数)。 */
|
|
25
|
+
export const RECOVERY_SHOWN_FILE = 'recovery-code-shown'
|
|
26
|
+
|
|
27
|
+
/** @returns {string} 标记文件绝对路径(root 为壳工程根)。 */
|
|
28
|
+
export function recoveryShownPath(root) {
|
|
29
|
+
return join(root, 'data', 'identity', RECOVERY_SHOWN_FILE)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns {boolean} 标记是否存在(不存在 / 读不到都当「没呈现过」)。 */
|
|
33
|
+
export function readShownMarker(root) {
|
|
34
|
+
return existsSync(recoveryShownPath(root))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 写「已呈现过」标记(内容是时间戳,给人看的;判据只看存在性)。
|
|
39
|
+
* 目录自建:老安装升级上来时 data/identity/ 可能还没有(自举没跑过),不能 ENOENT。
|
|
40
|
+
*
|
|
41
|
+
* @param {string} root 壳工程根
|
|
42
|
+
*/
|
|
43
|
+
export function writeShownMarker(root) {
|
|
44
|
+
const file = recoveryShownPath(root)
|
|
45
|
+
mkdirSync(join(file, '..'), { recursive: true })
|
|
46
|
+
writeFileSync(file, `${new Date().toISOString()}\n`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 呈现一次的判定(纯函数——判据的核心在这里,Electron 只是执行器)。
|
|
51
|
+
*
|
|
52
|
+
* @param {adepShowDecisionInput} input
|
|
53
|
+
* @returns {boolean} true = 应该弹一次
|
|
54
|
+
*/
|
|
55
|
+
export function shouldShowFirstTime(input) {
|
|
56
|
+
return input.codeReadable && !input.alreadyShown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 首次呈现对话框的形态(文案与按钮顺序在这里定,测试钉这里)。
|
|
61
|
+
*
|
|
62
|
+
* 按钮:0 = 复制到剪贴板,1 = 导出到文件,2 = 我已保存(关闭)。
|
|
63
|
+
* cancelId 必须是「我已保存」:Esc / 关窗不算「没保存」,只是不主动了。
|
|
64
|
+
*
|
|
65
|
+
* @param {adepDialogTextInput} input
|
|
66
|
+
* @returns {electronDialogOptions}
|
|
67
|
+
*/
|
|
68
|
+
export function firstShowDialogOptions(input) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'info',
|
|
71
|
+
title: '设备恢复码(请妥善保存)',
|
|
72
|
+
message: '这是你的设备恢复码,服务端无法重发',
|
|
73
|
+
detail:
|
|
74
|
+
`${input.code}\n\n` +
|
|
75
|
+
'它用于在换机 / 重装后重新认领本设备的同步分区。服务端只存摘要,' +
|
|
76
|
+
'丢了码等于放弃认领——请导出或复制到安全的地方保存。',
|
|
77
|
+
buttons: ['复制到剪贴板', '导出到文件…', '我已保存'],
|
|
78
|
+
defaultId: 2,
|
|
79
|
+
cancelId: 2,
|
|
80
|
+
noLink: true,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 再次查看 / 导出用的通用对话框形态(菜单入口;同样只呈现不设置默认复制)。
|
|
86
|
+
*
|
|
87
|
+
* @param {adepDialogTextInput} input
|
|
88
|
+
* @returns {electronDialogOptions}
|
|
89
|
+
*/
|
|
90
|
+
export function reviewDialogOptions(input) {
|
|
91
|
+
return {
|
|
92
|
+
type: 'info',
|
|
93
|
+
title: '设备恢复码',
|
|
94
|
+
message: '这是你的设备恢复码,服务端无法重发',
|
|
95
|
+
detail:
|
|
96
|
+
`${input.code}\n\n` +
|
|
97
|
+
'换机 / 重装后在登录流程提交它即可重新认领本设备的同步分区。',
|
|
98
|
+
buttons: ['复制到剪贴板', '导出到文件…', '关闭'],
|
|
99
|
+
defaultId: 2,
|
|
100
|
+
cancelId: 2,
|
|
101
|
+
noLink: true,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 导出文件的内容(导出即用户自担保存责任,头两行把后果写明)。
|
|
107
|
+
*
|
|
108
|
+
* @param {adepExportTextInput} input
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function exportRecoveryText(input) {
|
|
112
|
+
const when = typeof input.at === 'string' ? input.at : new Date().toISOString()
|
|
113
|
+
return [
|
|
114
|
+
'adep 设备恢复码',
|
|
115
|
+
`应用:${input.appName}(项目 ${input.projectSlug})`,
|
|
116
|
+
`导出时间:${when}`,
|
|
117
|
+
'',
|
|
118
|
+
'⚠️ 服务端只存本码的摘要、无法重发;丢了码等于放弃本设备同步分区的认领。',
|
|
119
|
+
'请把本文件存到密码管理器或其他安全的地方,不要留在下载目录里。',
|
|
120
|
+
'',
|
|
121
|
+
`恢复码:${input.code}`,
|
|
122
|
+
'',
|
|
123
|
+
].join('\n')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 导出文件缺省名(带项目 slug,多项目时一眼可分)。 */
|
|
127
|
+
export function exportDefaultFileName(projectSlug) {
|
|
128
|
+
return `adep-recovery-code-${projectSlug}.txt`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 造恢复码动作集。**全部依赖注入**(dialog / clipboard 来自 Electron,
|
|
133
|
+
* 但不在本模块 import——单测用假件真跑行为,判据不撞渲染器)。
|
|
134
|
+
*
|
|
135
|
+
* @param {adepRecoveryActionsInput} input
|
|
136
|
+
* @returns {adepRecoveryActions}
|
|
137
|
+
*/
|
|
138
|
+
export function createRecoveryActions(input) {
|
|
139
|
+
const { root, readRecoveryCode, appName, projectSlug } = input
|
|
140
|
+
const deps = {
|
|
141
|
+
dialog: input.deps.dialog,
|
|
142
|
+
clipboard: input.deps.clipboard,
|
|
143
|
+
log: input.deps.log ?? (() => {}),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 读码并归一「可读」判定(空串 / 非串都算不可读)。 */
|
|
147
|
+
async function readCode() {
|
|
148
|
+
const code = await readRecoveryCode()
|
|
149
|
+
return typeof code === 'string' && code !== '' ? code : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function copyToClipboard(code) {
|
|
153
|
+
deps.clipboard.writeText(code)
|
|
154
|
+
deps.log('[adep-app] 恢复码已复制到剪贴板')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function exportToFile() {
|
|
158
|
+
const code = await readCode()
|
|
159
|
+
if (code === null) return { exported: false, reason: 'CODE_UNREADABLE' }
|
|
160
|
+
const result = await deps.dialog.showSaveDialog({
|
|
161
|
+
title: '导出设备恢复码',
|
|
162
|
+
defaultPath: exportDefaultFileName(projectSlug),
|
|
163
|
+
properties: ['createDirectory', 'showOverwriteConfirmation'],
|
|
164
|
+
})
|
|
165
|
+
if (result.canceled || typeof result.filePath !== 'string' || result.filePath === '') {
|
|
166
|
+
return { exported: false, reason: 'CANCELED' }
|
|
167
|
+
}
|
|
168
|
+
writeFileSync(result.filePath, exportRecoveryText({ code, appName, projectSlug }))
|
|
169
|
+
deps.log(`[adep-app] 恢复码已导出到 ${result.filePath}`)
|
|
170
|
+
return { exported: true, filePath: result.filePath }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 用户在对话框里点完按钮后的分流(showOnceIfNeeded / showAgain 共用)。 */
|
|
174
|
+
async function handleDialogChoice(code, choice) {
|
|
175
|
+
if (choice.response === 0) await copyToClipboard(code)
|
|
176
|
+
if (choice.response === 1) await exportToFile()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
/**
|
|
181
|
+
* 首次呈现:码可读且未呈现过 ⇒ 弹一次并写标记。幂等——调用多少次只弹一次。
|
|
182
|
+
* 码不可读(未签发 / 存储失败)不弹也不写标记(下次启动还有机会)。
|
|
183
|
+
*/
|
|
184
|
+
async showOnceIfNeeded() {
|
|
185
|
+
const code = await readCode()
|
|
186
|
+
const decision = shouldShowFirstTime({
|
|
187
|
+
codeReadable: code !== null,
|
|
188
|
+
alreadyShown: readShownMarker(root),
|
|
189
|
+
})
|
|
190
|
+
if (!decision) return { shown: false }
|
|
191
|
+
const choice = await deps.dialog.showMessageBox(firstShowDialogOptions({ code, appName }))
|
|
192
|
+
await handleDialogChoice(code, choice)
|
|
193
|
+
writeShownMarker(root)
|
|
194
|
+
deps.log('[adep-app] 恢复码已首次呈现(此后从应用菜单「凭据」进入)')
|
|
195
|
+
return { shown: true }
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/** 菜单入口:再次查看。码不可读时给出可解释的失败(不是静默空窗)。 */
|
|
199
|
+
async showAgain() {
|
|
200
|
+
const code = await readCode()
|
|
201
|
+
if (code === null) {
|
|
202
|
+
await deps.dialog.showMessageBox({
|
|
203
|
+
type: 'warning',
|
|
204
|
+
title: '恢复码不可读',
|
|
205
|
+
message: '没有找到可读的设备恢复码',
|
|
206
|
+
detail:
|
|
207
|
+
'可能原因:本应用尚未完成设备签发(离线首启需要网络),或受保护存储读取失败。' +
|
|
208
|
+
'排查用 adep app doctor(identity 项)。',
|
|
209
|
+
buttons: ['好'],
|
|
210
|
+
noLink: true,
|
|
211
|
+
})
|
|
212
|
+
return { shown: false, reason: 'CODE_UNREADABLE' }
|
|
213
|
+
}
|
|
214
|
+
const choice = await deps.dialog.showMessageBox(reviewDialogOptions({ code, appName }))
|
|
215
|
+
await handleDialogChoice(code, choice)
|
|
216
|
+
return { shown: true }
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
exportToFile,
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 应用菜单「凭据」子菜单项(Menu.buildFromTemplate 直接吃)。
|
|
223
|
+
* click 里 void 掉 promise:菜单回调不吃 await,失败走 log(不崩主进程)。
|
|
224
|
+
*/
|
|
225
|
+
menuItems() {
|
|
226
|
+
return [
|
|
227
|
+
{ label: '查看恢复码…', click: () => void this.showAgain().catch((e) => deps.log(`[adep-app] 恢复码查看失败:${e?.message ?? e}`)) },
|
|
228
|
+
{ label: '导出恢复码…', click: () => void this.exportToFile().catch((e) => deps.log(`[adep-app] 恢复码导出失败:${e?.message ?? e}`)) },
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @typedef {Object} adepShowDecisionInput
|
|
236
|
+
* @property {boolean} codeReadable 受保护存储里读得到非空恢复码
|
|
237
|
+
* @property {boolean} alreadyShown 标记文件是否存在
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @typedef {Object} adepDialogTextInput
|
|
242
|
+
* @property {string} code 恢复码明文
|
|
243
|
+
* @property {string} appName 应用名(对话框标题用)
|
|
244
|
+
*/
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @typedef {Object} adepExportTextInput
|
|
248
|
+
* @property {string} code 恢复码明文
|
|
249
|
+
* @property {string} appName 应用名
|
|
250
|
+
* @property {string} projectSlug 项目 slug
|
|
251
|
+
* @property {string} [at] 导出时间(缺省 now;测试注入固定值)
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @typedef {Object} adepRecoveryActionsInput
|
|
256
|
+
* @property {string} root 壳工程根
|
|
257
|
+
* @property {() => Promise<string|null>} readRecoveryCode 受保护存储读口(identity-store)
|
|
258
|
+
* @property {string} appName 应用名
|
|
259
|
+
* @property {string} projectSlug 项目 slug
|
|
260
|
+
* @property {adepRecoveryDeps} deps Electron 面板 / 剪贴板 / 日志(全注入)
|
|
261
|
+
*/
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @typedef {Object} adepRecoveryDeps
|
|
265
|
+
* @property {electronDialog} dialog
|
|
266
|
+
* @property {electronClipboard} clipboard
|
|
267
|
+
* @property {(line: string) => void} [log]
|
|
268
|
+
*/
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @typedef {Object} electronDialog
|
|
272
|
+
* @property {(options: electronDialogOptions) => Promise<{response: number, checked: boolean}>} showMessageBox
|
|
273
|
+
* @property {(options: electronSaveDialogOptions) => Promise<{canceled: boolean, filePath?: string}>} showSaveDialog
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Electron 剪贴板面(main.mjs 传 `clipboard`;测试传假件)。
|
|
278
|
+
*
|
|
279
|
+
* @typedef {Object} electronClipboard
|
|
280
|
+
* @property {(text: string) => void} writeText
|
|
281
|
+
*/
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* @typedef {Object} electronDialogOptions
|
|
285
|
+
* @property {string} type
|
|
286
|
+
* @property {string} title
|
|
287
|
+
* @property {string} message
|
|
288
|
+
* @property {string} detail
|
|
289
|
+
* @property {string[]} buttons
|
|
290
|
+
* @property {number} defaultId
|
|
291
|
+
* @property {number} cancelId
|
|
292
|
+
* @property {boolean} noLink
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @typedef {Object} electronSaveDialogOptions
|
|
297
|
+
* @property {string} title
|
|
298
|
+
* @property {string} defaultPath
|
|
299
|
+
* @property {string[]} properties
|
|
300
|
+
*/
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 壳内后台同步驱动(APP-033 第 4 项;transport 本身是 APP-032 的 `sync-transport.ts`)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么单独一个模块
|
|
5
|
+
*
|
|
6
|
+
* 主进程要的是「启动后跑一轮、之后按间隔重跑、失败不打扰用户」,而 `SyncTransport`
|
|
7
|
+
* 只提供一次往返。把这层留在 `main.mjs` 里就等于**不可测**(main 一 import 就要 electron),
|
|
8
|
+
* 于是「并发两轮会不会打架」「失败一轮之后还跑不跑」这类判定只能靠读代码猜——
|
|
9
|
+
* 本仓最反对的就是这个(APP-030/APP-031 的读数教训)。
|
|
10
|
+
*
|
|
11
|
+
* ## 四条约定
|
|
12
|
+
*
|
|
13
|
+
* 1. **首轮有延迟**(`initialDelayMs`,缺省 5s,且不超过间隔):窗口要先把页面画出来,
|
|
14
|
+
* 同步不该和首屏抢网络;延迟是**可注入**的,测试不必真等。
|
|
15
|
+
* 2. **不重叠**:上一轮没结束就跳过本轮(`skipped: true`),不排队——排队会在断网时堆成
|
|
16
|
+
* 一串永远不会成功的请求。
|
|
17
|
+
* 3. **失败不致命**:任一轮抛错只落一条带原因码的日志(`SYNC_BLOCKED`),下一轮照常;
|
|
18
|
+
* 本地读写完全不经过这里,所以「同步挂了」≠「app 挂了」。
|
|
19
|
+
* 4. **每轮必打一行日志**:0 行日志与「循环根本没在跑」不可区分(定时器类老坑)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 同步循环的缺省参数(改了会影响所有壳;测试全部显式注入)。 */
|
|
23
|
+
export const SYNC_LOOP_DEFAULTS = {
|
|
24
|
+
/** 首轮延迟:让首屏先出来(与「补首轮 delay=min(60s, interval)」同思路)。 */
|
|
25
|
+
initialDelayMs: 5_000,
|
|
26
|
+
/** 轮间隔:5 分钟。单主同步是「最终一致」,不该更密。 */
|
|
27
|
+
intervalMs: 300_000,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 失败原因码(日志首词,排障先看码)。 */
|
|
31
|
+
export const SYNC_BLOCKED_CODE = 'SYNC_BLOCKED'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} adepSyncLoopOptions
|
|
35
|
+
* @property {adepSyncTransport} sync 设备端 transport(APP-032 的 createSyncTransport 返回值)
|
|
36
|
+
* @property {(line: string) => void} [log] 每轮必打一行(0 行日志与没跑不可区分)
|
|
37
|
+
* @property {number} [intervalMs] 轮间隔(缺省 5 分钟)
|
|
38
|
+
* @property {number} [initialDelayMs] 首轮延迟(缺省 5s,且不超过间隔)
|
|
39
|
+
* @property {(fn: () => void, ms: number) => unknown} [setTimeout] 定时器注入(测试用假时钟)
|
|
40
|
+
* @property {(handle: unknown) => void} [clearTimeout]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} adepSyncTransport
|
|
45
|
+
* @property {(limit?: number) => Promise<{push: {pushed: number, remaining: number}, pull: {applied: number, hasMore: boolean}}>} sync 一次往返(push + pull)
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 造一个后台同步循环。**不会自动启动**(`start()` 才排第一轮)。
|
|
50
|
+
*
|
|
51
|
+
* @param {adepSyncLoopOptions} options
|
|
52
|
+
*/
|
|
53
|
+
export function createSyncLoop(options) {
|
|
54
|
+
const {
|
|
55
|
+
sync,
|
|
56
|
+
log = () => {},
|
|
57
|
+
intervalMs = SYNC_LOOP_DEFAULTS.intervalMs,
|
|
58
|
+
initialDelayMs = SYNC_LOOP_DEFAULTS.initialDelayMs,
|
|
59
|
+
setTimeout: schedule = globalThis.setTimeout,
|
|
60
|
+
clearTimeout: cancel = globalThis.clearTimeout,
|
|
61
|
+
} = options
|
|
62
|
+
|
|
63
|
+
/** @type {unknown} */
|
|
64
|
+
let timer = null
|
|
65
|
+
let running = false
|
|
66
|
+
let stopped = false
|
|
67
|
+
let cycles = 0
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 跑一轮(并发保护在这里,`start()` 与手动调用共用)。
|
|
71
|
+
*
|
|
72
|
+
* 返回结构性结果而不是 throw:调用方(主进程)没有任何理由为了同步失败去炸窗口。
|
|
73
|
+
*/
|
|
74
|
+
const runOnce = async () => {
|
|
75
|
+
if (running) {
|
|
76
|
+
log(`[adep-sync] 上一轮未结束,跳过本轮(第 ${cycles + 1} 轮)`)
|
|
77
|
+
return { skipped: true, cycles }
|
|
78
|
+
}
|
|
79
|
+
running = true
|
|
80
|
+
cycles += 1
|
|
81
|
+
const ordinal = cycles
|
|
82
|
+
try {
|
|
83
|
+
const result = await sync.sync()
|
|
84
|
+
log(
|
|
85
|
+
`[adep-sync] 第 ${ordinal} 轮完成:上行 ${result.push.pushed} 条(余 ${result.push.remaining})` +
|
|
86
|
+
`· 下行 ${result.pull.applied} 条${result.pull.hasMore ? '(还有更多)' : ''}`
|
|
87
|
+
)
|
|
88
|
+
return { skipped: false, cycles: ordinal, result }
|
|
89
|
+
} catch (error) {
|
|
90
|
+
log(
|
|
91
|
+
`[adep-sync] ${SYNC_BLOCKED_CODE}: 第 ${ordinal} 轮失败——${
|
|
92
|
+
error instanceof Error ? error.message : String(error)
|
|
93
|
+
}(本地读写不受影响,下一轮重试)`
|
|
94
|
+
)
|
|
95
|
+
return { skipped: false, cycles: ordinal, error }
|
|
96
|
+
} finally {
|
|
97
|
+
running = false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 定时器回调:先清掉句柄再跑,跑完(无论成败)才排下一轮 ⇒ 天然不重叠。 */
|
|
102
|
+
const tick = () => {
|
|
103
|
+
timer = null
|
|
104
|
+
void runOnce().finally(() => {
|
|
105
|
+
if (!stopped) timer = schedule(tick, intervalMs)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
/** 排首轮(延迟 = min(initialDelayMs, intervalMs))。重复调用无副作用。 */
|
|
111
|
+
start() {
|
|
112
|
+
if (stopped || timer !== null) return
|
|
113
|
+
const delay = Math.min(initialDelayMs, intervalMs)
|
|
114
|
+
log(`[adep-sync] 后台同步已排程:首轮 ${delay}ms 后,之后每 ${intervalMs}ms 一轮`)
|
|
115
|
+
timer = schedule(tick, delay)
|
|
116
|
+
},
|
|
117
|
+
/** 停循环(窗口关闭 / 退出前调用)。已在跑的那一轮不打断——它自己会结束。 */
|
|
118
|
+
stop() {
|
|
119
|
+
stopped = true
|
|
120
|
+
if (timer !== null) {
|
|
121
|
+
cancel(timer)
|
|
122
|
+
timer = null
|
|
123
|
+
}
|
|
124
|
+
log(`[adep-sync] 后台同步已停止(共 ${cycles} 轮)`)
|
|
125
|
+
},
|
|
126
|
+
/** 手动跑一轮(e2e / 诊断用;与定时轮共享并发保护)。 */
|
|
127
|
+
runOnce,
|
|
128
|
+
/** 已跑的轮次(含失败轮)。 */
|
|
129
|
+
get cycles() {
|
|
130
|
+
return cycles
|
|
131
|
+
},
|
|
132
|
+
/** 是否有定时器在等(`false` 且 `cycles === 0` ⇒ 根本没启动)。 */
|
|
133
|
+
get scheduled() {
|
|
134
|
+
return timer !== null
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -32,9 +32,9 @@ npx cap add ios # 生成 ios/ Xcode 工程
|
|
|
32
32
|
npx cap add android # 生成 android/ Gradle 工程
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
>
|
|
36
|
-
>
|
|
37
|
-
>
|
|
35
|
+
> 两端 bundle id 由 CLI 从 slug 推导为**剔除连字符**的形态(`top.adep.demoapp`):
|
|
36
|
+
> capacitor 的 appId 双端共用且校验禁连字符,而下划线形态会让 iOS 在开发者后台签
|
|
37
|
+
> App ID 时被拒——剔除是唯一双端全绿的映射,`cap add` 直接通过,无需手工修改。
|
|
38
38
|
|
|
39
39
|
## 打 unsigned 安装包
|
|
40
40
|
|
|
@@ -74,12 +74,50 @@ const result = await client.invokeFunction('hello', { name: 'world' })
|
|
|
74
74
|
(如 `ACCESS_FINE_LOCATION`)。本插件**不**主动索取任何系统权限。
|
|
75
75
|
- 设备端函数(离线运行时)请求原生能力:`await window.__ADEP_APP__.invokeNative('geolocation', {})`。
|
|
76
76
|
|
|
77
|
-
##
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
(
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
## 原生桥方法(机器可读契约)
|
|
78
|
+
|
|
79
|
+
<!-- 下方名单必须与 plugins/adep-bridge/dist/plugin.js 的对象键相等
|
|
80
|
+
(app-bridge-registration-contract.test.ts 钉住,改一边不改另一边会红) -->
|
|
81
|
+
|
|
82
|
+
JS 面可调用:`invokeNative` / `getOwnerKey` / `getRecoveryCode` / `sqliteRun` / `sqliteAll` / `sqliteGet`
|
|
83
|
+
|
|
84
|
+
## ownerKey 签发与存储(§2.11.5 设备身份与恢复)
|
|
85
|
+
|
|
86
|
+
**当前状态(APP-027):首启原生自举已接线,ownerKey 由服务端签发、只存 Keychain / Keystore。**
|
|
87
|
+
|
|
88
|
+
- 存储:`AdepKeychain.swift`(`kSecClassGenericPassword`)/ `AdepKeystore.kt`
|
|
89
|
+
(Android Keystore + AES/GCM,密文落 SharedPreferences)。明文不落 JS 侧、不落明文文件。
|
|
90
|
+
- **签发(首启,仅原生侧)**:`AdepIdentity.swift` / `AdepIdentity.kt` 在 `load()` 后由后台线程
|
|
91
|
+
调 `POST <platformUrl>/api/v1/projects/<projectSlug>/app-installations`(body `{ platform }`),
|
|
92
|
+
成功后写 Keychain / Keystore,**再**把设备本地库的 key 换成它(见下)。
|
|
93
|
+
- **幂等**:本地已有合法 ownerKey ⇒ 不发请求(一枚安装只签发一次)。
|
|
94
|
+
- **失败不阻断**:断网 / 5xx / 响应形态不符 ⇒ 不写空值、不覆盖既有值、页面照常加载,
|
|
95
|
+
日志带原因码(`IDENTITY_CONFIG_MISSING` / `IDENTITY_REQUEST_FAILED` /
|
|
96
|
+
`IDENTITY_RESPONSE_INVALID` / `IDENTITY_STORE_FAILED`),下次启动重试。
|
|
97
|
+
- **页面拿不到写凭据的通道**:写只发生在原生侧(`AdepIdentityBootstrap` +
|
|
98
|
+
`saveOwnerKey`),`dist/plugin.js` 的对象键集合不变——XSS 不能替换 ownerKey 劫持分区。
|
|
99
|
+
- 页面侧只读:`getOwnerKey` 返回明文(未签发 / 签发失败时为 `null`)。单主同步
|
|
100
|
+
(`/app-sync/*` 的 `Bearer <ownerKey>`)依赖它,因此调用方必须处理 null。
|
|
101
|
+
- 设备本地 SQLite(APP-025/026)的库 key **= ownerKey**;ownerKey 缺失时(离线首启)先落
|
|
102
|
+
**硬编码缺省**(`AdepSqlite.fallbackKey`,只能算「有锁」不算安全 key),ownerKey 一到即
|
|
103
|
+
`PRAGMA rekey` 原地换成正式 key(数据行保留,旧 key 立刻失效);重启时若换代发生在退出前,
|
|
104
|
+
`open()` 会自动探测并升级老库。**不处理就是「换 key 后老库打不开」**。
|
|
105
|
+
- **恢复码(APP-028)**:签发响应里的 `recoveryCode` **同批落库**(iOS 独立 Keychain service
|
|
106
|
+
`adep.recoveryCode`;Android 独立 prefs 文件 `adep_keystore_recovery`),与 ownerKey 同生命周期、
|
|
107
|
+
同覆盖语义。服务端只存它的**摘要**、明文只在这次响应里出现一次 ⇒ 丢即该安装的分区永久不可认领,
|
|
108
|
+
所以写入后还要**读回校验**,任一环节失败则本次不落 ownerKey(下次启动重签)。
|
|
109
|
+
**呈现与导出(APP-034)**:页面经 `getRecoveryCode` 读明文(与 `getOwnerKey` 同一
|
|
110
|
+
只读面),由页面产品代码决定一次性弹层 / 设置项 / 导出的节奏——服务端无法重发,
|
|
111
|
+
丢了码等于放弃认领,呈现文案必须写明这一点。
|
|
112
|
+
- 恢复码认领端点 `/app-installations/recover` 已交付;认领后的换键流程另单。
|
|
113
|
+
|
|
114
|
+
## iOS / Android 打包前提(APP-027)
|
|
115
|
+
|
|
116
|
+
- iOS:签发走 `URLSession` + https,无需额外权限;若平台地址是明文 http,需在
|
|
117
|
+
`Info.plist` 配 ATS 例外(生产不建议)。
|
|
118
|
+
- Android:插件 `AndroidManifest.xml` 显式声明 `INTERNET`(安装期权限、无弹窗)——
|
|
119
|
+
不声明就等于把「能不能发请求」押在宿主 app 的 manifest 上,缺了症状是每次启动
|
|
120
|
+
`IDENTITY_REQUEST_FAILED`(看起来像「服务端没部署」)。
|
|
83
121
|
|
|
84
122
|
## iOS 约束(§2.11.4 / §2.11.7)
|
|
85
123
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// swift-tools-version: 5.9
|
|
2
|
+
//
|
|
3
|
+
// adep-bridge 的 Swift Package Manager 清单(APP-015)。
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ **这是双形态中的一支,不是替代品**:CocoaPods 形态(`ios/AdepBridge.podspec`)**必须同时保留**——
|
|
6
|
+
// Capacitor 官方口径是「插件同时是 CocoaPods 与 SPM 库」;自 Capacitor 8 起 `cap add ios` 默认建 SPM 工程,
|
|
7
|
+
// 没有本文件的插件会被静默跳过(运行时表现为 "plugin is not implemented")。两支并存,各管一条消费路径。
|
|
8
|
+
//
|
|
9
|
+
// 命名与布局**不能随意改**(改了 Capacitor CLI 就找不到这个包):
|
|
10
|
+
// - 本文件必须在 **npm 包根**:`cap sync ios` 按 npm 包根路径写入 `.package(name:path:)`
|
|
11
|
+
// (`path: ".../node_modules/@adep/bridge"`),落在 `ios/` 下不会被消费。
|
|
12
|
+
// - `name` / product 名 = `AdepBridge`:由 npm 名 `@adep/bridge` 按官方规则派生
|
|
13
|
+
// (剥 `@`、`/`→`_`、分段首字母大写)。用别的名字会在 app 侧报
|
|
14
|
+
// "product 'X' required by package 'capapp-spm' target 'CapApp-SPM' not found"。
|
|
15
|
+
// 与既有 `s.name = 'AdepBridge'`(podspec)、`@objc(AdepBridge)` 一致。
|
|
16
|
+
// - `path: "ios/Sources"`:既有 Swift 源码就平铺在这(与 podspec 的 `source_files` glob 同一目录),
|
|
17
|
+
// 避免为 SPM 搬目录而牵动 podspec 与 capacitor.plugin.json。
|
|
18
|
+
//
|
|
19
|
+
// 版本与平台下限**两形态必须一致**(本仓判据见 `packages/cli/src/__tests__/adep-bridge-spm.test.ts`):
|
|
20
|
+
// - `platforms: [.iOS(.v14)]` ↔ podspec 的 `s.ios.deployment_target = '14.0'`。
|
|
21
|
+
// Capacitor 7 官方迁移要求把 iOS 下限从 13 抬到 14;两端不一致 = 同一插件对同一消费方给出不同平台约束。
|
|
22
|
+
// - `capacitor-swift-pm` 用 `from: "7.0.0"`(区间):app 侧按自己装的 `@capacitor/ios` 钉 exact 版本,
|
|
23
|
+
// 这里给区间让 SPM 能同时解析两者;写 `branch:` / `exact:` 会与 app 侧冲突。
|
|
24
|
+
// 与 podspec 的 `s.dependency 'Capacitor'`(无版本约束)对应——语义都是「跟随消费方的 Capacitor 版本」。
|
|
25
|
+
// - `Capacitor` **与** `Cordova` 两个 product 都要声明:只写 `Capacitor` 会报
|
|
26
|
+
// `fatal error: module 'Cordova' not found`(Capacitor.framework 的头文件里 `@_exported import Cordova`)。
|
|
27
|
+
import PackageDescription
|
|
28
|
+
|
|
29
|
+
let package = Package(
|
|
30
|
+
name: "AdepBridge",
|
|
31
|
+
platforms: [.iOS(.v14)],
|
|
32
|
+
products: [
|
|
33
|
+
.library(name: "AdepBridge", targets: ["AdepBridge"])
|
|
34
|
+
],
|
|
35
|
+
dependencies: [
|
|
36
|
+
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0"),
|
|
37
|
+
// APP-025:SQLCipher 加密(binary xcframework,GitHub Releases 分发 ⇒ 构建期需网络;
|
|
38
|
+
// checksum 39f02d2f… 由 SQLCipher.swift 的 Package.swift 钉死,SPM 自动校验完整性)。
|
|
39
|
+
.package(url: "https://github.com/sqlcipher/SQLCipher.swift.git", from: "4.19.0")
|
|
40
|
+
],
|
|
41
|
+
targets: [
|
|
42
|
+
.target(
|
|
43
|
+
name: "AdepBridge",
|
|
44
|
+
dependencies: [
|
|
45
|
+
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
|
46
|
+
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
|
47
|
+
.product(name: "SQLCipher", package: "SQLCipher.swift"),
|
|
48
|
+
],
|
|
49
|
+
path: "ios/Sources"
|
|
50
|
+
)
|
|
51
|
+
]
|
|
52
|
+
)
|
|
@@ -1,15 +1,37 @@
|
|
|
1
|
+
// APP-044:capacitor 7 的 android 核心是纯 Java,生成的原生工程(root build.gradle / app 模块)
|
|
2
|
+
// 没有任何 Kotlin 工具链——本模块的 .kt 源若无下面的 buildscript + kotlin-android,会被 AGP
|
|
3
|
+
// **静默忽略**(不报错、出一个空 aar,装机后表现为「原生桥不可用:AdepBridge 未注册」)。
|
|
4
|
+
// Kotlin 版本与 @capacitor/android 自身的 kotlin-bom 档对齐(1.9.25)。
|
|
5
|
+
buildscript {
|
|
6
|
+
ext.kotlin_version = '1.9.25'
|
|
7
|
+
repositories {
|
|
8
|
+
google()
|
|
9
|
+
mavenCentral()
|
|
10
|
+
}
|
|
11
|
+
dependencies {
|
|
12
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
apply plugin: 'com.android.library'
|
|
17
|
+
apply plugin: 'kotlin-android'
|
|
2
18
|
|
|
3
19
|
android {
|
|
4
20
|
namespace "com.adep.bridge"
|
|
5
|
-
compileSdk
|
|
21
|
+
compileSdk 35
|
|
6
22
|
defaultConfig {
|
|
7
|
-
|
|
23
|
+
// APP-026:SQLCipher for Android 官方下限 API 23(上游 README Compatibility),
|
|
24
|
+
// minSdk 从 22 抬到 23。
|
|
25
|
+
minSdk 23
|
|
8
26
|
}
|
|
9
27
|
compileOptions {
|
|
10
28
|
sourceCompatibility JavaVersion.VERSION_17
|
|
11
29
|
targetCompatibility JavaVersion.VERSION_17
|
|
12
30
|
}
|
|
31
|
+
// kotlin-android 缺省按宿主 JDK(21)出字节码,与上面 Java 17 撞「Inconsistent JVM-target」。
|
|
32
|
+
kotlinOptions {
|
|
33
|
+
jvmTarget = '17'
|
|
34
|
+
}
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
repositories {
|
|
@@ -18,5 +40,14 @@ repositories {
|
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
dependencies {
|
|
21
|
-
|
|
43
|
+
// capacitor 不把 android 库发布到 Maven(只在 npm 里、以子工程形态被 include):
|
|
44
|
+
// 依赖必须引本地工程 ':capacitor-android'(compileOnly——运行时副本由 app 模块带,
|
|
45
|
+
// 不重复打包)。写 maven 坐标 com.getcapacitor:android 在任何机器上都解析不到(APP-044)。
|
|
46
|
+
compileOnly project(':capacitor-android')
|
|
47
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
48
|
+
// APP-026:设备本地 SQLite 落盘即密文(与 iOS APP-025 的 SQLCipher 同语义)。
|
|
49
|
+
// sqlcipher-android 4.x 支持 API 23+;minSdk 22 的壳要抬到 23(SQLCipher 官方下限)。
|
|
50
|
+
implementation 'net.zetetic:sqlcipher-android:4.6.1@aar'
|
|
51
|
+
// sqlcipher-android 的 SupportSQLiteOpenHelper 接口来自 androidx.sqlite(上游 README 依赖声明)。
|
|
52
|
+
implementation 'androidx.sqlite:sqlite:2.4.0'
|
|
22
53
|
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
2
|
<!--
|
|
3
|
-
AdepBridge 插件(library
|
|
3
|
+
AdepBridge 插件(library 模块)**不声明任何运行时权限**(相机 / 定位 / 通知等全部留空)。
|
|
4
|
+
|
|
4
5
|
门控语义(PRD §2.11.5):未在应用 manifest 的 permissions 声明的能力即不可用,
|
|
5
|
-
|
|
6
|
-
由壳工程在 cap add 生成的应用级 AndroidManifest.xml 里配相应 uses-permission
|
|
7
|
-
|
|
6
|
+
且不得在运行时申请——插件本体不主动索取任何需要用户同意的权限;应用声明了哪些权限,
|
|
7
|
+
由壳工程在 cap add 生成的应用级 AndroidManifest.xml 里配相应 uses-permission(README 有映射指引)。
|
|
8
|
+
|
|
9
|
+
唯一例外是 INTERNET(APP-027):它是**安装期权限**(无弹窗、无运行时申请),
|
|
10
|
+
而首启 ownerKey 签发(`AdepIdentityBootstrap` → POST /api/v1/projects/<slug>/app-installations)
|
|
11
|
+
依赖它。不在此显式声明就等于把「能不能发请求」押在宿主 app 的 manifest 上——
|
|
12
|
+
缺了它症状是每次启动都 `IDENTITY_REQUEST_FAILED`(看起来像「服务端没部署」)。
|
|
8
13
|
-->
|
|
9
|
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
14
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
15
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
16
|
+
</manifest>
|