@adep/cli 0.1.16 → 0.2.0
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 +101 -12
- package/dist/index.js +27134 -10430
- package/dist/vite.js +298 -35
- package/package.json +8 -6
- package/templates/app-desktop/README.md +51 -0
- package/templates/app-desktop/main.mjs +108 -0
- package/templates/app-desktop/offline-runtime.mjs +129 -0
- package/templates/app-desktop/package.json +49 -0
- package/templates/app-desktop/preload.cjs +45 -0
- package/templates/app-desktop/template.json +14 -0
- package/templates/app-desktop/window.mjs +53 -0
- package/templates/app-mobile/README.md +89 -0
- package/templates/app-mobile/adep.permissions.json +4 -0
- package/templates/app-mobile/bridge/gate.mjs +88 -0
- package/templates/app-mobile/capacitor.config.ts +23 -0
- package/templates/app-mobile/package.json +24 -0
- package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +22 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +9 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +86 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +81 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepPermissionGate.kt +81 -0
- package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +10 -0
- package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +44 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +14 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +69 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +58 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +82 -0
- package/templates/app-mobile/plugins/adep-bridge/package.json +14 -0
- package/templates/app-mobile/template.json +16 -0
- package/templates/app-mobile/web/adep-bridge.js +56 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron 主进程(app-desktop 模板,APP-001)。
|
|
3
|
+
*
|
|
4
|
+
* 职责(PRD §2.11 路径 A:在线运行时 + 桌面壳):
|
|
5
|
+
* 1. 注册自定义 scheme `adep-app://`——`file://` 直接加载 ESM bundle 会被 CORS 拦
|
|
6
|
+
* (`file://` origin 为 null,ES module 跨源请求一律被拒),必须走自定义协议;
|
|
7
|
+
* 2. `adep-app://bundle/<path>` 从工程内 `app/site/` 提供静态产物(带 MIME 与 SPA 回退);
|
|
8
|
+
* 3. 创建 BrowserWindow,经 additionalArguments 把 adep.app.json 配置传给沙箱 preload。
|
|
9
|
+
*
|
|
10
|
+
* 页面侧经 preload 注入的 `__ADEP_APP__` 拿到 platformUrl 后,用 `@adep/client`
|
|
11
|
+
* 绝对地址 invoke 云函数(在线运行时,函数逻辑迭代不需要重新发版)。
|
|
12
|
+
*
|
|
13
|
+
* 主进程是 .mjs(Electron 28+ 支持 ESM 主进程):启动流程用 top-level await
|
|
14
|
+
* 直写,比 .then() 链少一层回调;启动失败即响亮崩溃(unhandled rejection)。
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
19
|
+
import { app, ipcMain, net, protocol } from 'electron'
|
|
20
|
+
import { createWindow } from './window.mjs'
|
|
21
|
+
import { createOfflineRuntime, hasOfflineBundle } from './offline-runtime.mjs'
|
|
22
|
+
|
|
23
|
+
const APP_SCHEME = 'adep-app'
|
|
24
|
+
const SITE_DIR = 'app/site'
|
|
25
|
+
/**
|
|
26
|
+
* 壳工程根目录(= 本文件所在目录)。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ ESM 里**没有** `__dirname`(那是 CJS 的全局量):主进程走 Node 的 ESM 加载器时它是
|
|
29
|
+
* `undefined`,拿去 join 会抛 TypeError(应用启动即崩)。必须自己从 `import.meta.url` 取。
|
|
30
|
+
*/
|
|
31
|
+
const APP_ROOT = dirname(fileURLToPath(import.meta.url))
|
|
32
|
+
|
|
33
|
+
/** 从壳工程根读 adep.app.json(init 时生成;字段校验归 @adep/cli 的 schema)。 */
|
|
34
|
+
function loadAppConfig() {
|
|
35
|
+
const raw = JSON.parse(readFileSync(join(APP_ROOT, 'adep.app.json'), 'utf8'))
|
|
36
|
+
if (typeof raw.platformUrl !== 'string' || raw.platformUrl === '') {
|
|
37
|
+
throw new Error('[adep-app] adep.app.json 缺少 platformUrl(请重新执行 adep app init)')
|
|
38
|
+
}
|
|
39
|
+
if (typeof raw.projectSlug !== 'string' || raw.projectSlug === '') {
|
|
40
|
+
throw new Error('[adep-app] adep.app.json 缺少 projectSlug(请重新执行 adep app init)')
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
platformUrl: raw.platformUrl,
|
|
44
|
+
projectSlug: raw.projectSlug,
|
|
45
|
+
appName: typeof raw.appName === 'string' && raw.appName !== '' ? raw.appName : raw.projectSlug,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// registerSchemesAsPrivileged 必须在 app ready **之前**调用(ready 后注册会抛错):
|
|
50
|
+
// standard + secure 让 `adep-app://` 拥有标准 origin 语义(fetch / ESM 可用),
|
|
51
|
+
// supportFetchAPI + corsEnabled 让页面内 fetch 与 ES module 请求按标准 CORS 工作。
|
|
52
|
+
protocol.registerSchemesAsPrivileged([
|
|
53
|
+
{
|
|
54
|
+
scheme: APP_SCHEME,
|
|
55
|
+
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true },
|
|
56
|
+
},
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
await app.whenReady()
|
|
60
|
+
const config = loadAppConfig()
|
|
61
|
+
|
|
62
|
+
// 流式协议处理器:adep-app://bundle/<path> → app/site/<path>。
|
|
63
|
+
// 用 net.fetch(pathToFileURL(...)) 而非手拼 Response:前者自动带 Content-Type
|
|
64
|
+
//(ESM 对 MIME 严格校验,手写 Response 不给 MIME 会被静默拒载)。
|
|
65
|
+
protocol.handle(APP_SCHEME, async (request) => {
|
|
66
|
+
const { host, pathname } = new URL(request.url)
|
|
67
|
+
if (host !== 'bundle') {
|
|
68
|
+
return new Response(`未知 host "${host}"`, { status: 404 })
|
|
69
|
+
}
|
|
70
|
+
const siteRoot = join(APP_ROOT, SITE_DIR)
|
|
71
|
+
const relative = decodeURIComponent(pathname).replace(/^\/+/, '')
|
|
72
|
+
const rootUrl = pathToFileURL(siteRoot).href
|
|
73
|
+
const targetUrl = pathToFileURL(join(siteRoot, relative)).href
|
|
74
|
+
// 目录穿越防线:归一化后的目标必须仍在 app/site/ 内。
|
|
75
|
+
if (!targetUrl.startsWith(`${rootUrl}/`)) {
|
|
76
|
+
return new Response('路径越界', { status: 403 })
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await net.fetch(targetUrl)
|
|
80
|
+
} catch {
|
|
81
|
+
// SPA 回退:产物内路由未命中时回落 index.html(前端发布形态统一 SPA)。
|
|
82
|
+
if (relative !== 'index.html') {
|
|
83
|
+
return net.fetch(pathToFileURL(join(siteRoot, 'index.html')).href)
|
|
84
|
+
}
|
|
85
|
+
return new Response('未找到入口产物 app/site/index.html', { status: 404 })
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// 离线运行时(PRD §2.11.4):app/offline/ 存在即装配设备端执行器,页面函数调用走 ipc。
|
|
90
|
+
// 在线形态零开销(hasOfflineBundle 只做一次 existsSync);装配失败 = 启动即崩(fail loud)。
|
|
91
|
+
let offline = null
|
|
92
|
+
if (hasOfflineBundle()) {
|
|
93
|
+
offline = await createOfflineRuntime()
|
|
94
|
+
ipcMain.handle('adep-offline:invoke', (_event, name, args) => offline.executor.invoke(name, args))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const win = await createWindow(config, { offline: offline !== null })
|
|
98
|
+
await win.loadURL(`${APP_SCHEME}://bundle/index.html`)
|
|
99
|
+
|
|
100
|
+
app.on('activate', () => {
|
|
101
|
+
// macOS:点 Dock 图标且无窗口时重建(Electron 官方生命周期约定)。
|
|
102
|
+
if (app.getAllWindows().length === 0) void createWindow(config)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
app.on('window-all-closed', () => {
|
|
106
|
+
// Windows / Linux:关窗即退出;macOS 保持常驻(Dock 惯例)。
|
|
107
|
+
if (process.platform !== 'darwin') app.quit()
|
|
108
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌面壳离线运行时装配(APP-008 范围 4 / PRD §2.11.4 运行时随包分发)。
|
|
3
|
+
*
|
|
4
|
+
* 消费 `.adepack` 内的 `runtime/executor.mjs`(@adep/app-runtime 的编译产物,服务端
|
|
5
|
+
* Bun.build 在构建期打进包):
|
|
6
|
+
*
|
|
7
|
+
* 读包(manifest / functions/index.json / functions/*.mjs / db/*.sql / storage/**)
|
|
8
|
+
* → 造 DeviceBridge(node:sqlite + 只读文件桥 + 空 env——离线包永不下发 secret)
|
|
9
|
+
* → executor.createLocalExecutor(白名单路由:offline:true 本地执行,false 返回
|
|
10
|
+
* FN_OFFLINE_ONLY 且提示在线调用方式)
|
|
11
|
+
* → 返回 { executor, manifest, sqlite, version } 供主进程 ipc 面使用。
|
|
12
|
+
*
|
|
13
|
+
* 壳工程是**独立 npm 工程**(装不了私有 workspace 包),运行时随包分发是本模板对
|
|
14
|
+
* 「接 @adep/app-runtime」的唯一可行落地;e2e 黄金测试(e2e/cli/app-offline.spec.ts)
|
|
15
|
+
* 用仓库内 @adep/app-runtime 真实现做 host-side harness,对本装配跑同一判定面,
|
|
16
|
+
* 把随包分发的语义分叉风险钉在 CI 里(AGENTS §9:拷贝必须点名分叉并双向对账)。
|
|
17
|
+
*/
|
|
18
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
23
|
+
|
|
24
|
+
/** 壳工程根目录(= 本文件所在目录)。⚠️ ESM 没有 `__dirname`,须从 import.meta.url 取。 */
|
|
25
|
+
const APP_ROOT = dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
|
|
27
|
+
/** 离线产物目录(CLI `adep app build --offline` 注入的位置,与在线 `app/site/` 并列)。 */
|
|
28
|
+
const OFFLINE_DIR = join(APP_ROOT, 'app', 'offline')
|
|
29
|
+
|
|
30
|
+
/** 本地 SQLite 库文件(设备本地数据;首次装配时由 schema.sql + baseline.db.sql 幂等初始化)。 */
|
|
31
|
+
const LOCAL_DB_PATH = join(APP_ROOT, 'data', 'offline.sqlite')
|
|
32
|
+
|
|
33
|
+
/** 壳是否处于离线运行时形态(包内存在 manifest 即视为已注入离线产物)。 */
|
|
34
|
+
export function hasOfflineBundle() {
|
|
35
|
+
return existsSync(join(OFFLINE_DIR, 'manifest.json'))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 离线函数名 → 源码的懒读取(避免启动即读全部大函数;executor 需要时按名取)。 */
|
|
39
|
+
function readFunctionSource(name) {
|
|
40
|
+
const file = join(OFFLINE_DIR, 'functions', `${name}.mjs`)
|
|
41
|
+
if (!existsSync(file)) {
|
|
42
|
+
throw new Error(`[adep-app] 离线包缺少函数源码 functions/${name}.mjs(包损坏或注入不完整)`)
|
|
43
|
+
}
|
|
44
|
+
return readFileSync(file, 'utf8')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 装配离线运行时:装载 executor(data URL 动态 import,引擎无关标准 ESM 语义)、
|
|
49
|
+
* 初始化本地库、造桥。任何一步失败即 throw(壳启动即响亮崩溃,不留半离线坏状态)。
|
|
50
|
+
*/
|
|
51
|
+
export async function createOfflineRuntime() {
|
|
52
|
+
if (!hasOfflineBundle()) {
|
|
53
|
+
throw new Error('[adep-app] 未检测到离线产物 app/offline/manifest.json:请先执行 adep app build --offline')
|
|
54
|
+
}
|
|
55
|
+
const executorSource = readFileSync(join(OFFLINE_DIR, 'runtime', 'executor.mjs'), 'utf8')
|
|
56
|
+
// data URL import:包内字节不进磁盘副本,进程内直接装载(bundle 是纯 ESM,无 node: 运行时依赖)。
|
|
57
|
+
const executorModule = await import(
|
|
58
|
+
`data:text/javascript;base64,${Buffer.from(executorSource).toString('base64')}`
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const manifest = JSON.parse(readFileSync(join(OFFLINE_DIR, 'manifest.json'), 'utf8'))
|
|
62
|
+
const index = JSON.parse(
|
|
63
|
+
readFileSync(join(OFFLINE_DIR, 'functions', 'index.json'), 'utf8')
|
|
64
|
+
)
|
|
65
|
+
const functions =
|
|
66
|
+
index.functions === undefined
|
|
67
|
+
? []
|
|
68
|
+
: index.functions.map((fn) => ({ name: fn.name, source: readFunctionSource(fn.name), offline: fn.offline === true }))
|
|
69
|
+
|
|
70
|
+
// 本地库:schema.sql(owned + baseline 结构)+ baseline.db.sql(只读快照)幂等初始化。
|
|
71
|
+
mkdirSync(dirname(LOCAL_DB_PATH), { recursive: true })
|
|
72
|
+
const sqlite = new DatabaseSync(LOCAL_DB_PATH)
|
|
73
|
+
const schemaSql = readFileSync(join(OFFLINE_DIR, 'db', 'schema.sql'), 'utf8')
|
|
74
|
+
const baselineSql = readFileSync(join(OFFLINE_DIR, 'db', 'baseline.db.sql'), 'utf8')
|
|
75
|
+
if (schemaSql.trim() !== '') sqlite.exec(schemaSql)
|
|
76
|
+
if (baselineSql.trim() !== '') sqlite.exec(baselineSql)
|
|
77
|
+
|
|
78
|
+
// DeviceBridge:SQLite 桥(node:sqlite)+ 只读预置资源桥(app/offline/storage/**)+ 无原生能力。
|
|
79
|
+
const sqliteBridge = {
|
|
80
|
+
run: async (sql, params) => {
|
|
81
|
+
const stmt = sqlite.prepare(sql)
|
|
82
|
+
const result = params === undefined ? stmt.run() : stmt.run(...(params))
|
|
83
|
+
return { changes: Number(result.changes) }
|
|
84
|
+
},
|
|
85
|
+
all: async (sql, params) => {
|
|
86
|
+
const stmt = sqlite.prepare(sql)
|
|
87
|
+
return (params === undefined ? stmt.all() : stmt.all(...(params)))
|
|
88
|
+
},
|
|
89
|
+
get: async (sql, params) => {
|
|
90
|
+
const stmt = sqlite.prepare(sql)
|
|
91
|
+
const row = params === undefined ? stmt.get() : stmt.get(...(params))
|
|
92
|
+
return (row === undefined ? null : (row))
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
const storageRoot = join(OFFLINE_DIR, 'storage')
|
|
96
|
+
const filesBridge = {
|
|
97
|
+
writeFile: async (path, _data) => {
|
|
98
|
+
throw new Error(`[adep-app] 离线包预置资源只读:storage/${path}`)
|
|
99
|
+
},
|
|
100
|
+
readFile: async (path) => {
|
|
101
|
+
const file = join(storageRoot, path)
|
|
102
|
+
if (!existsSync(file)) throw new Error(`[adep-app] 离线包缺少预置资源 storage/${path}`)
|
|
103
|
+
return new Uint8Array(readFileSync(file))
|
|
104
|
+
},
|
|
105
|
+
removeFile: async (path) => {
|
|
106
|
+
throw new Error(`[adep-app] 离线包预置资源只读:storage/${path}`)
|
|
107
|
+
},
|
|
108
|
+
listFiles: async (prefix = '') => {
|
|
109
|
+
// 预置资源面在离线包里是固定清单(manifest.contents 里 storage/**),不落盘扫描。
|
|
110
|
+
const contents = manifest['contents']
|
|
111
|
+
return (contents ?? [])
|
|
112
|
+
.filter((entry) => entry.path.startsWith('storage/') && entry.path.startsWith(`storage/${prefix}`))
|
|
113
|
+
.map((entry) => ({ path: entry.path.slice('storage/'.length), size: entry.size ?? 0 }))
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
const bridge = {
|
|
117
|
+
sqlite: sqliteBridge,
|
|
118
|
+
files: filesBridge,
|
|
119
|
+
// 原生能力门控在原生侧(ADR-0007 硬约束 3);本壳未声明任何原生能力 → 调用即拒绝。
|
|
120
|
+
native: undefined,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// env:离线包永不下发 secret 值(§4.2);public 环境变量同样不进包(manifest app 段无 env 面)。
|
|
124
|
+
const env = { publicVars: {}, secretKeys: [] }
|
|
125
|
+
const local = executorModule.createLocalExecutor({ functions, bridge, env })
|
|
126
|
+
const version = readFileSync(join(OFFLINE_DIR, 'runtime', 'version.txt'), 'utf8').trim()
|
|
127
|
+
|
|
128
|
+
return { executor: local, manifest, sqlite, version }
|
|
129
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectSlug}}-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "{{appName}} —— AgentDeploy 桌面应用(Electron 壳,在线 + 离线运行时)",
|
|
6
|
+
"main": "main.mjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "electron .",
|
|
9
|
+
"dist": "electron-builder"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"electron": "^44.0.0",
|
|
13
|
+
"electron-builder": "^26.0.0"
|
|
14
|
+
},
|
|
15
|
+
"build": {
|
|
16
|
+
"appId": "{{appId}}",
|
|
17
|
+
"productName": "{{appName}}",
|
|
18
|
+
"asar": false,
|
|
19
|
+
"directories": {
|
|
20
|
+
"output": "dist"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"main.mjs",
|
|
24
|
+
"window.mjs",
|
|
25
|
+
"preload.cjs",
|
|
26
|
+
"adep.app.json",
|
|
27
|
+
"app/site/**/*",
|
|
28
|
+
"app/offline/**/*",
|
|
29
|
+
"data/**/*"
|
|
30
|
+
],
|
|
31
|
+
"mac": {
|
|
32
|
+
"target": [
|
|
33
|
+
"dmg"
|
|
34
|
+
],
|
|
35
|
+
"category": "public.app-category.productivity"
|
|
36
|
+
},
|
|
37
|
+
"win": {
|
|
38
|
+
"target": [
|
|
39
|
+
"nsis"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"linux": {
|
|
43
|
+
"target": [
|
|
44
|
+
"AppImage"
|
|
45
|
+
],
|
|
46
|
+
"category": "Utility"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preload(app-desktop 模板,APP-001):contextBridge 注入只读配置 `__ADEP_APP__`。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 文件必须是 `.cjs`:Electron 沙箱 preload **不支持 ESM**(官方约束,sandbox
|
|
5
|
+
* 预加载脚本在隔离的 CJS 环境里执行),`.mjs` 会被静默跳过。
|
|
6
|
+
*
|
|
7
|
+
* 沙箱 preload 读不到文件、也读不到普通环境变量,配置由主进程经
|
|
8
|
+
* `webPreferences.additionalArguments` 以 `--adep-xxx=value` 形式传入(main.mjs 从
|
|
9
|
+
* adep.app.json 读取后注入)。页面侧 `@adep/client` 以 platformUrl 绝对地址 invoke
|
|
10
|
+
* 云函数(在线运行时):
|
|
11
|
+
*
|
|
12
|
+
* const client = createAdepClient({ baseUrl: window.__ADEP_APP__.platformUrl })
|
|
13
|
+
*/
|
|
14
|
+
const { contextBridge, ipcRenderer } = require('electron')
|
|
15
|
+
|
|
16
|
+
function argvValue(name) {
|
|
17
|
+
const prefix = `--${name}=`
|
|
18
|
+
const arg = process.argv.find((entry) => entry.startsWith(prefix))
|
|
19
|
+
return arg === undefined ? '' : arg.slice(prefix.length)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PLATFORM_URL = argvValue('adep-platform-url')
|
|
23
|
+
const PROJECT_SLUG = argvValue('adep-project-slug')
|
|
24
|
+
const APP_NAME = argvValue('adep-app-name')
|
|
25
|
+
const OFFLINE = argvValue('adep-offline') === '1'
|
|
26
|
+
|
|
27
|
+
if (PLATFORM_URL === '' || PROJECT_SLUG === '') {
|
|
28
|
+
// fail loud:缺配置的壳等于「页面能开、数据全挂」的静默坏状态。
|
|
29
|
+
throw new Error(
|
|
30
|
+
'[adep-app] 缺少运行配置 --adep-platform-url / --adep-project-slug(由主进程从 adep.app.json 注入)'
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
contextBridge.exposeInMainWorld('__ADEP_APP__', {
|
|
35
|
+
platformUrl: PLATFORM_URL,
|
|
36
|
+
projectSlug: PROJECT_SLUG,
|
|
37
|
+
appName: APP_NAME,
|
|
38
|
+
// 离线形态(app/offline/ 已注入):函数调用经主进程 LocalFunctionExecutor 本地执行。
|
|
39
|
+
...(OFFLINE
|
|
40
|
+
? {
|
|
41
|
+
mode: 'offline',
|
|
42
|
+
invoke: (name, args) => ipcRenderer.invoke('adep-offline:invoke', name, args),
|
|
43
|
+
}
|
|
44
|
+
: { mode: 'online' }),
|
|
45
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"code": "app-desktop",
|
|
3
|
+
"name": "桌面应用壳(Electron)",
|
|
4
|
+
"description": "Electron 桌面壳工程:BrowserWindow + adep-app:// 自定义协议加载 app/site/ 产物,在线运行时(云函数经 platformUrl 服务端执行)",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"variables": [
|
|
7
|
+
{
|
|
8
|
+
"name": "projectSlug",
|
|
9
|
+
"description": "平台项目 slug(写入包名 / adep.app.json / appId 推导)"
|
|
10
|
+
},
|
|
11
|
+
{ "name": "appName", "description": "应用显示名(窗口标题 / electron-builder productName)" },
|
|
12
|
+
{ "name": "appId", "description": "electron-builder appId(top.adep.<slug>)" }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserWindow 装配(从 main.mjs 拆出,保持主流程可读)。
|
|
3
|
+
*
|
|
4
|
+
* 配置传递:沙箱 preload 读不到文件与环境变量,经 `additionalArguments` 以
|
|
5
|
+
* `--adep-xxx=value` 形式把 adep.app.json 的只读配置带进 preload(见 preload.cjs)。
|
|
6
|
+
*
|
|
7
|
+
* @typedef {Object} AppConfig
|
|
8
|
+
* @property {string} platformUrl 平台地址(页面侧 @adep/client 的 baseUrl)
|
|
9
|
+
* @property {string} projectSlug 平台项目 slug
|
|
10
|
+
* @property {string} appName 应用显示名
|
|
11
|
+
*/
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { BrowserWindow, shell } from 'electron'
|
|
15
|
+
|
|
16
|
+
/** 壳工程根目录(= 本文件所在目录)。⚠️ ESM 没有 `__dirname`,须从 import.meta.url 取。 */
|
|
17
|
+
const APP_ROOT = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 创建主窗口并注入壳配置。
|
|
21
|
+
*
|
|
22
|
+
* @param {AppConfig} config adep.app.json 解析出的只读配置
|
|
23
|
+
* @returns {Promise<BrowserWindow>}
|
|
24
|
+
*/
|
|
25
|
+
export async function createWindow(config, options = {}) {
|
|
26
|
+
const win = new BrowserWindow({
|
|
27
|
+
width: 1280,
|
|
28
|
+
height: 800,
|
|
29
|
+
autoHideMenuBar: true,
|
|
30
|
+
webPreferences: {
|
|
31
|
+
preload: join(APP_ROOT, 'preload.cjs'),
|
|
32
|
+
// 沙箱 + contextIsolation 是 Electron 安全基线:preload 只经 contextBridge
|
|
33
|
+
// 暴露只读配置,页面拿不到 Node 能力(PRD §2.11 安全边界)。
|
|
34
|
+
sandbox: true,
|
|
35
|
+
contextIsolation: true,
|
|
36
|
+
nodeIntegration: false,
|
|
37
|
+
additionalArguments: [
|
|
38
|
+
`--adep-platform-url=${config.platformUrl}`,
|
|
39
|
+
`--adep-project-slug=${config.projectSlug}`,
|
|
40
|
+
`--adep-app-name=${config.appName}`,
|
|
41
|
+
`--adep-offline=${options.offline === true ? '1' : '0'}`,
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// 外链交给系统浏览器,不让 app 壳变成浏览器(Electron 官方 setWindowOpenHandler 约定)。
|
|
47
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
48
|
+
void shell.openExternal(url)
|
|
49
|
+
return { action: 'deny' }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return win
|
|
53
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# {{appName}}(AgentDeploy 移动应用壳)
|
|
2
|
+
|
|
3
|
+
`adep app init --platform mobile` 生成的 **Capacitor 移动壳工程**(PRD §2.11 路径 A:在线
|
|
4
|
+
运行时 + 移动壳):GUI(前端产物)随包本地加载,云函数经 HTTP 在服务端执行——**改函数
|
|
5
|
+
逻辑不需要重新发版**(§2.11.4 在线运行时)。
|
|
6
|
+
|
|
7
|
+
移动端与桌面端能力对齐(§2.11.1),差异只在打包壳与分发口径:iOS / Android 有应用商店
|
|
8
|
+
审核约束,签名与上架由开发者自行完成(见下方「签名与上架」,完整指引
|
|
9
|
+
[`docs/mobile-shell-signing.md`](../../docs/mobile-shell-signing.md))。
|
|
10
|
+
|
|
11
|
+
## 目录结构
|
|
12
|
+
|
|
13
|
+
- `capacitor.config.ts`:Capacitor 配置(`appId` = iOS bundle id,`webDir` = `app/site`);
|
|
14
|
+
- `package.json`:`@capacitor/*` 依赖 + 脚本(`cap:add:ios` / `cap:add:android` /
|
|
15
|
+
`build:ios` / `build:android` 等);
|
|
16
|
+
- `web/adep-bridge.js`:页面侧桥(与桌面 `preload.cjs` 同形,§3.2.8 硬约束④)——CLI
|
|
17
|
+
`adep app build` 时复制进 `app/site/adep-bridge.js` 并注入 `index.html`,暴露
|
|
18
|
+
`window.__ADEP_APP__`(`platformUrl` / `projectSlug` / `appName` / `invokeNative`);
|
|
19
|
+
- `bridge/gate.mjs`:权限门控核心的 **host-side harness 镜像**(CI 判定面;原生侧同规则);
|
|
20
|
+
- `plugins/adep-bridge/`:**本地 Capacitor 插件**——原生桥(iOS Swift / Android Kotlin):
|
|
21
|
+
原生侧权限门控 + ownerKey 存储(Keychain / Keystore)+ 原生能力 invoke 路由;
|
|
22
|
+
- `adep.permissions.json`:权限清单占位(CLI build 时按服务端打包配置重写进 `app/site/`);
|
|
23
|
+
- `adep.app.json`:壳配置(CLI init 生成,build 时复制进 `app/site/` 供页面桥读取)。
|
|
24
|
+
|
|
25
|
+
## 首次构建(一次性)
|
|
26
|
+
|
|
27
|
+
原生工程由 Capacitor 生成,平台不维护原生工程(§3.2.8):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npx cap add ios # 生成 ios/ Xcode 工程
|
|
32
|
+
npx cap add android # 生成 android/ Gradle 工程
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> Android:`cap add` 生成的 `android/app/build.gradle` 里 `applicationId` 默认取
|
|
36
|
+
> `capacitor.config.ts` 的 `appId`;按 `{{androidPackageId}}` 修改(applicationId 不允许
|
|
37
|
+
> 连字符,iOS bundle id 不允许下划线——两端无法共用一个 id)。
|
|
38
|
+
|
|
39
|
+
## 打 unsigned 安装包
|
|
40
|
+
|
|
41
|
+
推荐用 CLI(自动拉取**已发布**前端产物、按服务端打包配置写入权限清单、回写版本、出报告):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
adep app build --platform ios --dir . # xcodebuild archive → .xcarchive(unsigned)
|
|
45
|
+
adep app build --platform android --dir . # gradlew assembleRelease → unsigned APK
|
|
46
|
+
adep app build --platform ios --prepare-only # 只注入产物 + 报告,不调工具链
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
产物与构建报告输出到 `dist/`。**平台不出具任何签名**:签名 / 公证 / 上架由开发者本地完成
|
|
50
|
+
(完整指引见 `docs/mobile-shell-signing.md`)。
|
|
51
|
+
|
|
52
|
+
## 页面侧调用云函数(在线运行时)
|
|
53
|
+
|
|
54
|
+
页面代码与桌面同款,用 `@adep/client` 以 `platformUrl` 绝对地址 invoke:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createAdepClient } from '@adep/client/functions'
|
|
58
|
+
|
|
59
|
+
const client = createAdepClient({ baseUrl: window.__ADEP_APP__.platformUrl })
|
|
60
|
+
const result = await client.invokeFunction('hello', { name: 'world' })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 原生能力与权限门控(§2.11.5)
|
|
64
|
+
|
|
65
|
+
- 在平台「应用打包」设置页声明的权限(`permissions`)经 CLI build 写入
|
|
66
|
+
`app/site/adep.permissions.json`,随 `npx cap sync` 同步进两端原生资源
|
|
67
|
+
(Android `assets/public/` / iOS `Public/`)。
|
|
68
|
+
- **门控做在原生侧**(`AdepPermissionGate.swift` / `AdepPermissionGate.kt`):未声明的能力
|
|
69
|
+
调用直接返回 `PERMISSION_DENIED` 并记设备侧审计日志(`adep-audit.log`),**绝不在运行时
|
|
70
|
+
申请**。JS 侧不持有放行决策(客户端产物等同公开,决策放 JS 侧等于没有门)。
|
|
71
|
+
- **系统权限映射**:声明了某个能力后,还需在原生工程配相应系统权限——iOS 在
|
|
72
|
+
`ios/App/App/Info.plist` 加用途描述(如定位 `NSLocationWhenInUseUsageDescription`)、
|
|
73
|
+
Android 在 `android/app/src/main/AndroidManifest.xml` 加 `uses-permission`
|
|
74
|
+
(如 `ACCESS_FINE_LOCATION`)。本插件**不**主动索取任何系统权限。
|
|
75
|
+
- 设备端函数(离线运行时)请求原生能力:`await window.__ADEP_APP__.invokeNative('geolocation', {})`。
|
|
76
|
+
|
|
77
|
+
## ownerKey 存储(§2.11.5 设备身份与恢复)
|
|
78
|
+
|
|
79
|
+
安装首启时由壳原生侧向服务端签发端点换取 ownerKey,**只存 Keychain(iOS)/ Keystore
|
|
80
|
+
(Android)**:`AdepKeychain.swift`(`kSecClassGenericPassword`)/
|
|
81
|
+
`AdepKeystore.kt`(Android Keystore + AES/GCM,密文落 SharedPreferences)。明文不落 JS
|
|
82
|
+
侧 / 明文文件;插件提供 `getOwnerKey` / `setOwnerKey` / `deleteOwnerKey`(恢复码认领时换键)。
|
|
83
|
+
|
|
84
|
+
## iOS 约束(§2.11.4 / §2.11.7)
|
|
85
|
+
|
|
86
|
+
- **仅 UI 变更可自行发版**:函数逻辑变更需重新提交 App Store 审核(天级)。
|
|
87
|
+
业务频繁迭代且用户主要联网时选**在线运行时**(默认,本模板即在线)。
|
|
88
|
+
- **本平台不做 iOS 运行时下发离线包**(App Store 2.5.2 灰区,ADR-0008 第 10 条);
|
|
89
|
+
Android 不受此限。随二进制提交的 JS 属静态产物、不触发 2.5.2。
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 权限门控核心(APP-009,PRD §2.11.5「原生能力与权限」)。
|
|
3
|
+
*
|
|
4
|
+
* 「未声明即不可用,且不得在运行时申请」的门控**做在原生侧**(ADR-0007 配套硬约束 3):
|
|
5
|
+
* iOS Swift / Android Kotlin 的原生桥持有一份「已声明权限」清单,任何原生能力调用先过门控。
|
|
6
|
+
*
|
|
7
|
+
* 本文件是这条判定的 **host-side harness 镜像**(PRD §2.11.6 前言:可判定部分全部打在
|
|
8
|
+
* 引擎无关的 host-side harness 上,CI 用同一份设备端代码跑判定;真机只冒烟)。CI 没有
|
|
9
|
+
* iOS / Android 工具链,无法编译原生侧,因此用本文件在同一输入面上跑「未声明 → 拒绝 +
|
|
10
|
+
* 审计日志」的验收判据。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **双实现漂移防线**(AGENTS §9:同一算法两端各存一份拷贝必须点名分叉并双向对账):
|
|
13
|
+
* 原生桥与这里各自实现**同一规则**——`permissions` 白名单的 contains 判定 + 审计记录。
|
|
14
|
+
* 规则刻意极简(没有第二种判定形态),两侧共用同一份清单契约
|
|
15
|
+
* (`adep.permissions.json`,CLI build 时从服务端打包配置写入 `app/site/`)与同一错误码
|
|
16
|
+
* `PERMISSION_DENIED`。模板单测钉住「原生侧源码引用同一文件契约与同一错误码」,
|
|
17
|
+
* 防止一侧改规则另一侧不同步(漂移方向 = 实际放行面比声明面大)。
|
|
18
|
+
*
|
|
19
|
+
* 本文件**不**触碰系统权限 API(那是原生侧的事):已声明但系统未授权由原生侧返回
|
|
20
|
+
* 系统状态码;这里只做「声明面」判定与审计。设备端函数(离线运行时)经原生桥调用
|
|
21
|
+
* 原生能力时,两侧门控按同一规则先后拦截。
|
|
22
|
+
*
|
|
23
|
+
* 注意:本文件是随壳工程分发的 **.mjs**(设备端 / host-side 都直接跑),不写 TypeScript
|
|
24
|
+
* 语法;类型以 JSDoc 表达。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 未声明权限的机器码(原生侧与 JS 侧共用的契约常量)。 */
|
|
28
|
+
export const PERMISSION_DENIED = 'PERMISSION_DENIED'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 声明面判定:`capability` 是否在权限清单中。
|
|
32
|
+
*
|
|
33
|
+
* @param {readonly string[]} permissions 已声明权限清单(adep.permissions.json 的 permissions 数组)
|
|
34
|
+
* @param {string} capability 原生能力名(如 geolocation / camera / filesystem.read)
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isPermissionDeclared(permissions, capability) {
|
|
38
|
+
return Array.isArray(permissions) && permissions.includes(capability)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 内存审计 sink(host-side harness 用;每次调用追加一条)。
|
|
43
|
+
* 审计条目:`{ ts, capability, result, reason }`——拒绝原因固定为 `not_declared`(未声明),
|
|
44
|
+
* 该次尝试无论成功与否都留痕(审计不是「拒绝才记」,是「尝试即记」)。
|
|
45
|
+
*
|
|
46
|
+
* @returns {object} `{ sink, entries }`——sink.log(entry) 追加审计;entries 为已记条目数组。
|
|
47
|
+
* entry 形状:`{ ts, capability, result, reason }`,result 为 'granted' | 'denied',
|
|
48
|
+
* reason 为 'not_declared' | 'ok'。
|
|
49
|
+
*/
|
|
50
|
+
export function createMemoryAudit() {
|
|
51
|
+
const entries = []
|
|
52
|
+
return {
|
|
53
|
+
sink: {
|
|
54
|
+
log(entry) {
|
|
55
|
+
entries.push(entry)
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
entries,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 权限门控(由权限清单 + 审计 sink 装配;CLI 构建期生成清单,运行时装配门控)。
|
|
64
|
+
*
|
|
65
|
+
* @param {object} input 门控装配输入——`permissions`(readonly string[]:已声明权限清单)、
|
|
66
|
+
* `audit`(含 `log(entry)` 方法的审计 sink)。
|
|
67
|
+
* @returns {object} `{ check(capability) }`——check 返回 `{ ok: true }` 或
|
|
68
|
+
* `{ ok: false, code: 'PERMISSION_DENIED', message }`。
|
|
69
|
+
*/
|
|
70
|
+
export function createPermissionGate(input) {
|
|
71
|
+
return {
|
|
72
|
+
check(capability) {
|
|
73
|
+
const declared = isPermissionDeclared(input.permissions, capability)
|
|
74
|
+
input.audit.log({
|
|
75
|
+
ts: new Date().toISOString(),
|
|
76
|
+
capability,
|
|
77
|
+
result: declared ? 'granted' : 'denied',
|
|
78
|
+
reason: declared ? 'ok' : 'not_declared',
|
|
79
|
+
})
|
|
80
|
+
if (declared) return { ok: true }
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
code: PERMISSION_DENIED,
|
|
84
|
+
message: `原生能力 "${capability}" 未在应用 manifest 的 permissions 中声明(未声明即不可用,且不会在运行时申请)`,
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CapacitorConfig } from '@capacitor/cli'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capacitor 配置(APP-009 移动壳模板)。
|
|
5
|
+
*
|
|
6
|
+
* - `appId`:iOS bundle identifier(`{{iosBundleId}}`)。Android 的 applicationId 在
|
|
7
|
+
* `npx cap add android` 生成工程后按 `{{androidPackageId}}` 修改(applicationId 不允许连字符,
|
|
8
|
+
* iOS bundle id 不允许下划线——两端无法共用一个 id,见 README)。
|
|
9
|
+
* - `webDir`:CLI `adep app build` 注入的已发布前端产物(`app/site/`,与桌面壳同位置)。
|
|
10
|
+
* `adep.permissions.json`(原生侧权限门控清单)由 CLI 构建时写入 `app/site/` 下,随
|
|
11
|
+
* `npx cap sync` 同步进两端原生资源(Android `assets/public/` / iOS `Public/`)。
|
|
12
|
+
* - `server`:加载本地静态产物(不连 dev server);在线运行时下云函数经 `platformUrl` 服务端执行。
|
|
13
|
+
*/
|
|
14
|
+
const config: CapacitorConfig = {
|
|
15
|
+
appId: '{{iosBundleId}}',
|
|
16
|
+
appName: '{{appName}}',
|
|
17
|
+
webDir: 'app/site',
|
|
18
|
+
server: {
|
|
19
|
+
androidScheme: 'https',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default config
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectSlug}}-mobile-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "{{appName}} —— AgentDeploy 移动应用(Capacitor 壳,在线运行时)",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"cap:sync": "cap sync",
|
|
8
|
+
"cap:add:ios": "cap add ios",
|
|
9
|
+
"cap:add:android": "cap add android",
|
|
10
|
+
"build:ios": "adep app build --platform ios --dir .",
|
|
11
|
+
"build:android": "adep app build --platform android --dir .",
|
|
12
|
+
"open:ios": "cap open ios",
|
|
13
|
+
"open:android": "cap open android"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@adep/bridge": "file:plugins/adep-bridge",
|
|
17
|
+
"@capacitor/core": "^7.0.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@capacitor/cli": "^7.0.0",
|
|
21
|
+
"@capacitor/ios": "^7.0.0",
|
|
22
|
+
"@capacitor/android": "^7.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
android {
|
|
4
|
+
namespace "com.adep.bridge"
|
|
5
|
+
compileSdk 34
|
|
6
|
+
defaultConfig {
|
|
7
|
+
minSdk 22
|
|
8
|
+
}
|
|
9
|
+
compileOptions {
|
|
10
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
11
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
repositories {
|
|
16
|
+
google()
|
|
17
|
+
mavenCentral()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
dependencies {
|
|
21
|
+
implementation 'com.getcapacitor:android:7.0.0'
|
|
22
|
+
}
|