@workfly/miniapp-kit 0.1.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/index.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ // @workfly/miniapp-kit 类型声明(实现为纯 ESM JS,此处手写对外契约)。
2
+
3
+ export interface ManifestLike {
4
+ id: string
5
+ name?: string
6
+ version: string
7
+ ui?: { standalone?: { enabled?: boolean; entry?: string; render?: string; layout?: string } }
8
+ permissions?: (string | { id: string; reason?: string })[]
9
+ [key: string]: unknown
10
+ }
11
+
12
+ export interface Report {
13
+ errors: string[]
14
+ warnings: string[]
15
+ }
16
+
17
+ export interface ValidateResult {
18
+ report: Report
19
+ manifest: ManifestLike | null
20
+ }
21
+
22
+ export interface PackResult {
23
+ bytes: Buffer
24
+ fileCount: number
25
+ outName: string
26
+ }
27
+
28
+ export interface PackOptions {
29
+ dir: string
30
+ manifest: { id: string; version: string }
31
+ outName?: string
32
+ exclude?: { dirs?: string[]; files?: string[]; paths?: string[] }
33
+ }
34
+
35
+ export interface ProxyResult {
36
+ status: number
37
+ body: Record<string, unknown>
38
+ }
39
+
40
+ export const MAGIC: string
41
+ export const EXCLUDE_DIRS: Set<string>
42
+ export const EXCLUDE_FILES: Set<string>
43
+
44
+ export function packDir(opts: PackOptions): PackResult
45
+ export function stampComment(zipBytes: Uint8Array, comment: Uint8Array): Buffer
46
+ export function readMagic(bytes: Uint8Array): { id: string; version: string } | null
47
+
48
+ export function validateManifest(dir: string, opts?: { forPack?: boolean }): ValidateResult
49
+
50
+ export function shimSource(): string
51
+ export function injectShimHtml(
52
+ html: string,
53
+ configJson: string,
54
+ opts?: { relaxConnect?: boolean }
55
+ ): string
56
+
57
+ export function handleWfRequest(o: {
58
+ url: string
59
+ method?: string
60
+ data?: unknown
61
+ header?: Record<string, string>
62
+ dataType?: string
63
+ }): Promise<ProxyResult>
64
+ export function handleWfRsa(o: {
65
+ publicKey: string
66
+ data: string
67
+ padding?: string
68
+ }): Promise<ProxyResult>
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@workfly/miniapp-kit",
3
+ "version": "0.1.0",
4
+ "description": "WorkFly 小程序工具核心:.wfapp.zip 打包(魔数)、manifest 校验、wf dev shim 与网络/RSA 代理——Vite 插件、脚手架、主仓共用的真相源。",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "default": "./src/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "index.d.ts"
15
+ ],
16
+ "dependencies": {
17
+ "fflate": "^0.8.3"
18
+ },
19
+ "keywords": [
20
+ "workfly",
21
+ "miniapp",
22
+ "pack",
23
+ "wfapp"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "license": "MIT"
29
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // @workfly/miniapp-kit —— WorkFly 小程序工具核心,插件 / 脚手架 / 主仓 miniapp.mjs 共用的真相源。
2
+ export { MAGIC, EXCLUDE_DIRS, EXCLUDE_FILES, packDir, stampComment, readMagic } from './pack.js'
3
+ export { Report, validateManifest } from './validate.js'
4
+ export { shimSource, injectShimHtml } from './shim.js'
5
+ export { handleWfRequest, handleWfRsa } from './proxy.js'
package/src/pack.js ADDED
@@ -0,0 +1,100 @@
1
+ // .wfapp.zip 打包 —— 魔数写入的唯一真相源。
2
+ // 产物约定(docs/miniapp-manifest-spec.md §8):
3
+ // · 标准 ZIP,复合后缀 .wfapp.zip(过 IM 服务端后缀白名单)
4
+ // · manifest.json 强制为第一条目(宿主读首个本地文件头即可识别)
5
+ // · EOCD 注释区写魔数 `WFAPP1\0<id>\0<version>`,读文件末尾 ~64 字节即可零解包判定
6
+ import { readFileSync, statSync, readdirSync } from 'node:fs'
7
+ import { join, relative, basename } from 'node:path'
8
+ import { zipSync } from 'fflate'
9
+
10
+ export const MAGIC = 'WFAPP1'
11
+
12
+ // 不进包的工程文件(源码 / 构建配置 / 锁文件 / 上次产物 / standalone 内联工具链)
13
+ export const EXCLUDE_DIRS = new Set(['node_modules', 'src', 'tools', 'miniapp-kit'])
14
+ export const EXCLUDE_FILES = new Set([
15
+ 'vite.config.ts',
16
+ 'vite.config.js',
17
+ 'vite.config.mts',
18
+ 'vite.config.cts',
19
+ 'vite.config.mjs',
20
+ 'tsconfig.json',
21
+ 'tsconfig.node.json',
22
+ 'README.md',
23
+ 'package.json',
24
+ 'package-lock.json',
25
+ 'pnpm-lock.yaml',
26
+ 'yarn.lock',
27
+ '.gitignore',
28
+ '.npmrc',
29
+ '.DS_Store'
30
+ ])
31
+
32
+ /** 递归收集目录下需入包的文件 → { 'rel/path': Uint8Array }。exclude.paths 按包内相对路径精确排除(区分根 index.html 与 dist/index.html)。 */
33
+ function collect(dir, base, out, exclude) {
34
+ for (const name of readdirSync(dir)) {
35
+ const full = join(dir, name)
36
+ if (statSync(full).isDirectory()) {
37
+ if (!exclude.dirs.has(name)) collect(full, base, out, exclude)
38
+ continue
39
+ }
40
+ if (exclude.files.has(name) || name.endsWith('.wfapp.zip')) continue
41
+ const rel = relative(base, full).split(/[\\/]/).join('/')
42
+ if (exclude.paths.has(rel)) continue
43
+ out[rel] = new Uint8Array(readFileSync(full))
44
+ }
45
+ return out
46
+ }
47
+
48
+ /** 往 ZIP 末尾的 EOCD 注释区写入魔数:拼接 comment 字节 + 回填 2 字节 comment 长度。 */
49
+ export function stampComment(zipBytes, comment) {
50
+ const buf = Buffer.from(zipBytes)
51
+ let eocd = -1
52
+ for (let i = buf.length - 22; i >= 0; i--) {
53
+ if (buf.readUInt32LE(i) === 0x06054b50) {
54
+ eocd = i
55
+ break
56
+ }
57
+ }
58
+ if (eocd < 0) throw new Error('未找到 ZIP EOCD 记录,无法写魔数')
59
+ const out = Buffer.concat([buf, comment])
60
+ out.writeUInt16LE(comment.length, eocd + 20) // EOCD 偏移 +20 = comment length 字段
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * 收集工程目录 → zip(manifest.json 第一条目)→ 写魔数。不落盘,返回字节与元信息,由调用者写文件。
66
+ * @param {{ dir: string, manifest: { id: string, version: string }, outName?: string,
67
+ * exclude?: { dirs?: string[], files?: string[], paths?: string[] } }} opts
68
+ * @returns {{ bytes: Buffer, fileCount: number, outName: string }}
69
+ */
70
+ export function packDir({ dir, manifest, outName, exclude }) {
71
+ const ex = {
72
+ dirs: new Set([...EXCLUDE_DIRS, ...(exclude?.dirs ?? [])]),
73
+ files: new Set([...EXCLUDE_FILES, ...(exclude?.files ?? [])]),
74
+ paths: new Set(exclude?.paths ?? [])
75
+ }
76
+ const all = collect(dir, dir, {}, ex)
77
+ if (!all['manifest.json']) throw new Error('包内未找到 manifest.json')
78
+
79
+ // 重排为「manifest.json 第一条目」(fflate 按对象插入顺序写条目)
80
+ const ordered = { 'manifest.json': all['manifest.json'] }
81
+ for (const k of Object.keys(all)) if (k !== 'manifest.json') ordered[k] = all[k]
82
+
83
+ const zip = zipSync(ordered)
84
+ const magic = Buffer.from(`${MAGIC}\0${manifest.id}\0${manifest.version}`, 'utf8')
85
+ const bytes = stampComment(zip, magic)
86
+ return {
87
+ bytes,
88
+ fileCount: Object.keys(ordered).length,
89
+ outName: outName ?? `${basename(dir)}.wfapp.zip`
90
+ }
91
+ }
92
+
93
+ /** 反向:从包字节末尾读魔数,零解包识别 { id, version };非 WorkFly 包返回 null。 */
94
+ export function readMagic(bytes) {
95
+ const tail = Buffer.from(bytes.subarray(Math.max(0, bytes.length - 256))).toString('binary')
96
+ const idx = tail.lastIndexOf(MAGIC)
97
+ if (idx < 0) return null
98
+ const seg = tail.slice(idx).split('\0')
99
+ return seg.length >= 3 ? { id: seg[1], version: seg[2] } : null
100
+ }
package/src/proxy.js ADDED
@@ -0,0 +1,52 @@
1
+ // dev 网络 / RSA 代理 —— 纯函数,复刻主进程 net.fetch 与 crypto 语义(绕 CORS、支持 pkcs1)。
2
+ // 传输层(http server / connect 中间件)由调用者接;权限门控(net.fetch)也由调用者决定。
3
+ import { createPublicKey, publicEncrypt, constants } from 'node:crypto'
4
+
5
+ function safeJson(text) {
6
+ try {
7
+ return JSON.parse(text)
8
+ } catch {
9
+ return text
10
+ }
11
+ }
12
+
13
+ /**
14
+ * 代理 wf.request:复刻主进程 net.fetch(method/header/body 透传,JSON 自动序列化与解析)。
15
+ * @param {{ url: string, method?: string, data?: unknown, header?: Record<string,string>, dataType?: string }} o
16
+ * @returns {Promise<{ status: number, body: object }>}
17
+ */
18
+ export async function handleWfRequest(o) {
19
+ try {
20
+ const body =
21
+ o.data == null ? undefined : typeof o.data === 'string' ? o.data : JSON.stringify(o.data)
22
+ const header = { ...(o.header ?? {}) }
23
+ if (body && typeof o.data === 'object' && !header['Content-Type'] && !header['content-type'])
24
+ header['Content-Type'] = 'application/json'
25
+ const r = await fetch(o.url, { method: o.method ?? 'GET', headers: header, body })
26
+ const text = await r.text()
27
+ const data = (o.dataType ?? 'json') === 'json' && text ? safeJson(text) : text
28
+ return {
29
+ status: 200,
30
+ body: { data, statusCode: r.status, header: Object.fromEntries(r.headers.entries()) }
31
+ }
32
+ } catch (e) {
33
+ return { status: 502, body: { error: String(e?.message ?? e) } }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 代理 wf.bip.rsaEncrypt:Node crypto 支持 pkcs1(浏览器 SubtleCrypto 不支持)。
39
+ * @param {{ publicKey: string, data: string, padding?: string }} o
40
+ * @returns {Promise<{ status: number, body: object }>}
41
+ */
42
+ export async function handleWfRsa(o) {
43
+ try {
44
+ const key = createPublicKey(o.publicKey)
45
+ const padding =
46
+ o.padding === 'oaep' ? constants.RSA_PKCS1_OAEP_PADDING : constants.RSA_PKCS1_PADDING
47
+ const out = publicEncrypt({ key, padding }, Buffer.from(o.data, 'utf8'))
48
+ return { status: 200, body: { data: out.toString('base64') } }
49
+ } catch (e) {
50
+ return { status: 400, body: { error: String(e?.message ?? e) } }
51
+ }
52
+ }
@@ -0,0 +1,314 @@
1
+ // WorkFly 小程序调试宿主 · 浏览器 wf shim。
2
+ // 由 `node scripts/miniapp.mjs dev <dir>` 注入,在普通浏览器里模拟宿主注入的全局 wf,
3
+ // 用 dev server 代理网络 / RSA(绕 CORS、复刻主进程代理语义),按 manifest 权限门控,
4
+ // 并把每次 wf.* 调用记到右下角调试面板。真相源接口见 src/shared/wf-api.ts。
5
+ //
6
+ // ⚠ 仅供本地开发:加密存储不做真加密、UI 为简化实现,行为对齐而非像素级一致。
7
+ ;(function () {
8
+ const cfg = window.__WF_CONFIG__ || {}
9
+ const APP_ID = cfg.id || 'dev.app'
10
+ const PERMS = new Set(cfg.permissions || [])
11
+ const PROXY = cfg.proxyBase || ''
12
+ const NS = `wf:${APP_ID}:`
13
+
14
+ // ── 调试面板(右下角,记录每次调用 + 权限判定)────────────────────────────────
15
+ const panel = document.createElement('div')
16
+ panel.style.cssText =
17
+ 'position:fixed;right:12px;bottom:12px;width:320px;max-height:46vh;z-index:2147483647;' +
18
+ 'background:#11131a;color:#cdd3e0;font:11px/1.5 ui-monospace,Menlo,monospace;' +
19
+ 'border:1px solid #2a2f3c;border-radius:10px;overflow:hidden;box-shadow:0 8px 28px rgba(0,0,0,.4)'
20
+ panel.innerHTML =
21
+ '<div style="padding:7px 10px;background:#171a23;border-bottom:1px solid #2a2f3c;' +
22
+ 'display:flex;justify-content:space-between;align-items:center">' +
23
+ '<b style="color:#7fb0ff">wf 调试宿主</b>' +
24
+ '<span style="color:#6b7280">' +
25
+ APP_ID +
26
+ '</span></div><div id="__wf_log" style="padding:6px 8px;overflow:auto;max-height:40vh"></div>'
27
+ const ready = () => document.body && document.body.appendChild(panel)
28
+ if (document.body) ready()
29
+ else document.addEventListener('DOMContentLoaded', ready)
30
+
31
+ function logCall(name, ok, detail) {
32
+ const el = document.getElementById('__wf_log')
33
+ if (!el) return
34
+ const row = document.createElement('div')
35
+ row.style.cssText =
36
+ 'padding:3px 0;border-bottom:1px dashed #20242f;white-space:pre-wrap;word-break:break-all'
37
+ const tag = ok ? '<span style="color:#5fd29a">✓</span>' : '<span style="color:#ff8d8d">✗</span>'
38
+ row.innerHTML = `${tag} <b style="color:#cbd5e1">${name}</b> <span style="color:#7c8598">${detail || ''}</span>`
39
+ el.appendChild(row)
40
+ el.scrollTop = el.scrollHeight
41
+ }
42
+
43
+ function need(perm, name) {
44
+ if (PERMS.has(perm)) return
45
+ const msg = `权限未授权:'${perm}'(manifest.permissions 未声明)`
46
+ logCall(name, false, msg)
47
+ throw new Error(`${name}:fail ${msg}`)
48
+ }
49
+
50
+ // wx 双模:始终返回 Promise;若传了 success/fail/complete 也回调(errMsg 对齐 wx)。
51
+ function dual(name, opts, run, gate) {
52
+ const o = opts || {}
53
+ const p = (async () => {
54
+ if (gate) gate()
55
+ const r = await run(o)
56
+ logCall(name, true, summarize(r))
57
+ return r
58
+ })()
59
+ p.then(
60
+ (r) => {
61
+ if (o.success) o.success(Object.assign({}, r, { errMsg: `${name}:ok` }))
62
+ if (o.complete) o.complete({ errMsg: `${name}:ok` })
63
+ },
64
+ (e) => {
65
+ const errMsg = String(e && e.message ? e.message : e)
66
+ const m = errMsg.startsWith(`${name}:fail`) ? errMsg : `${name}:fail ${errMsg}`
67
+ if (!errMsg.startsWith(`${name}:fail`)) logCall(name, false, errMsg)
68
+ if (o.fail) o.fail({ errMsg: m })
69
+ if (o.complete) o.complete({ errMsg: m })
70
+ }
71
+ )
72
+ return p
73
+ }
74
+
75
+ function summarize(r) {
76
+ if (r === undefined) return 'ok'
77
+ try {
78
+ const s = JSON.stringify(r)
79
+ return s.length > 80 ? s.slice(0, 80) + '…' : s
80
+ } catch {
81
+ return 'ok'
82
+ }
83
+ }
84
+
85
+ // ── 存储(localStorage,按 app id 命名空间;encrypt 仅 base64,非真加密)──────────
86
+ function enc(v, encrypt) {
87
+ const s = JSON.stringify(v)
88
+ return encrypt ? 'b64:' + btoa(unescape(encodeURIComponent(s))) : s
89
+ }
90
+ function dec(raw) {
91
+ if (raw == null) return undefined
92
+ const s = raw.startsWith('b64:') ? decodeURIComponent(escape(atob(raw.slice(4)))) : raw
93
+ return JSON.parse(s)
94
+ }
95
+ function setSync(key, data, encrypt) {
96
+ if (encrypt) {
97
+ need('vault', 'setStorageSync')
98
+ console.warn('[wf dev] encrypt 存储在调试宿主里仅 base64,非真加密')
99
+ }
100
+ localStorage.setItem(NS + key, enc(data, encrypt))
101
+ }
102
+ function getSync(key, encrypt) {
103
+ if (encrypt) need('vault', 'getStorageSync')
104
+ return dec(localStorage.getItem(NS + key))
105
+ }
106
+ function storageKeys() {
107
+ return Object.keys(localStorage)
108
+ .filter((k) => k.startsWith(NS))
109
+ .map((k) => k.slice(NS.length))
110
+ }
111
+ function storageInfo() {
112
+ const keys = storageKeys()
113
+ const size = keys.reduce((n, k) => n + (localStorage.getItem(NS + k) || '').length, 0)
114
+ return { keys, currentSize: Math.ceil(size / 1024), limitSize: 5120 }
115
+ }
116
+
117
+ // ── UI(DOM 简化渲染)────────────────────────────────────────────────────────
118
+ function overlay(html, autoMs) {
119
+ const box = document.createElement('div')
120
+ box.style.cssText =
121
+ 'position:fixed;inset:0;z-index:2147483646;display:flex;align-items:center;justify-content:center;' +
122
+ 'background:rgba(0,0,0,.28);font-family:system-ui,sans-serif'
123
+ box.innerHTML = `<div style="background:#fff;color:#1d1d1f;border-radius:12px;padding:18px 20px;min-width:200px;max-width:80vw;box-shadow:0 10px 40px rgba(0,0,0,.3)">${html}</div>`
124
+ document.body.appendChild(box)
125
+ if (autoMs) setTimeout(() => box.remove(), autoMs)
126
+ return box
127
+ }
128
+ let loadingBox = null
129
+
130
+ // ── 网络 / RSA:走 dev server 代理(Node 侧 fetch / crypto,绕 CORS、支持 pkcs1)──
131
+ async function proxy(path, payload) {
132
+ const res = await fetch(PROXY + path, {
133
+ method: 'POST',
134
+ headers: { 'content-type': 'application/json' },
135
+ body: JSON.stringify(payload)
136
+ })
137
+ const out = await res.json()
138
+ if (!res.ok) throw new Error(out.error || `proxy ${res.status}`)
139
+ return out
140
+ }
141
+
142
+ const wf = {
143
+ // —— 网络 ——
144
+ request: (o) =>
145
+ dual(
146
+ 'request',
147
+ o,
148
+ (a) => proxy('/__wf/request', a),
149
+ () => need('net.fetch', 'request')
150
+ ),
151
+
152
+ // —— 存储(异步) ——
153
+ setStorage: (o) =>
154
+ dual('setStorage', o, (a) => {
155
+ setSync(a.key, a.data, a.encrypt)
156
+ }),
157
+ getStorage: (o) => dual('getStorage', o, (a) => ({ data: getSync(a.key, a.encrypt) })),
158
+ removeStorage: (o) =>
159
+ dual('removeStorage', o, (a) => {
160
+ localStorage.removeItem(NS + a.key)
161
+ }),
162
+ clearStorage: (o) =>
163
+ dual('clearStorage', o, () => {
164
+ storageKeys().forEach((k) => localStorage.removeItem(NS + k))
165
+ }),
166
+ getStorageInfo: (o) => dual('getStorageInfo', o, () => storageInfo()),
167
+
168
+ // —— 存储(同步) ——
169
+ setStorageSync: (key, data, encrypt) => {
170
+ setSync(key, data, encrypt)
171
+ logCall('setStorageSync', true, key)
172
+ },
173
+ getStorageSync: (key, encrypt) => {
174
+ const v = getSync(key, encrypt)
175
+ logCall('getStorageSync', true, key)
176
+ return v
177
+ },
178
+ removeStorageSync: (key) => {
179
+ localStorage.removeItem(NS + key)
180
+ logCall('removeStorageSync', true, key)
181
+ },
182
+ clearStorageSync: () => {
183
+ storageKeys().forEach((k) => localStorage.removeItem(NS + k))
184
+ logCall('clearStorageSync', true, '')
185
+ },
186
+ getStorageInfoSync: () => {
187
+ const i = storageInfo()
188
+ logCall('getStorageInfoSync', true, summarize(i))
189
+ return i
190
+ },
191
+
192
+ // —— 剪贴板 ——
193
+ setClipboardData: (o) =>
194
+ dual(
195
+ 'setClipboardData',
196
+ o,
197
+ (a) => navigator.clipboard.writeText(a.data),
198
+ () => need('clipboard', 'setClipboardData')
199
+ ),
200
+ getClipboardData: (o) =>
201
+ dual(
202
+ 'getClipboardData',
203
+ o,
204
+ async () => ({ data: await navigator.clipboard.readText() }),
205
+ () => need('clipboard', 'getClipboardData')
206
+ ),
207
+
208
+ // —— UI ——
209
+ showToast: (o) =>
210
+ dual('showToast', o, (a) => {
211
+ overlay(`<div style="text-align:center">${a.title || ''}</div>`, a.duration || 1500)
212
+ }),
213
+ hideToast: (o) => dual('hideToast', o, () => {}),
214
+ showLoading: (o) =>
215
+ dual('showLoading', o, (a) => {
216
+ if (loadingBox) loadingBox.remove()
217
+ loadingBox = overlay(`<div style="text-align:center">${a.title || '加载中…'}</div>`)
218
+ }),
219
+ hideLoading: (o) =>
220
+ dual('hideLoading', o, () => {
221
+ if (loadingBox) loadingBox.remove()
222
+ loadingBox = null
223
+ }),
224
+ showModal: (o) =>
225
+ dual(
226
+ 'showModal',
227
+ o,
228
+ (a) =>
229
+ new Promise((resolve) => {
230
+ const showCancel = a.showCancel !== false
231
+ const box = overlay(
232
+ `<div style="font-weight:600;margin-bottom:6px">${a.title || ''}</div>` +
233
+ `<div style="color:#555;margin-bottom:14px">${a.content || ''}</div>` +
234
+ `<div style="display:flex;gap:8px;justify-content:flex-end">` +
235
+ (showCancel
236
+ ? `<button id="__c" style="padding:6px 14px">${a.cancelText || '取消'}</button>`
237
+ : '') +
238
+ `<button id="__o" style="padding:6px 14px;background:#3461ff;color:#fff;border:0;border-radius:6px">${a.confirmText || '确定'}</button></div>`
239
+ )
240
+ box.querySelector('#__o').onclick = () => {
241
+ box.remove()
242
+ resolve({ confirm: true, cancel: false })
243
+ }
244
+ const c = box.querySelector('#__c')
245
+ if (c)
246
+ c.onclick = () => {
247
+ box.remove()
248
+ resolve({ confirm: false, cancel: true })
249
+ }
250
+ })
251
+ ),
252
+ showActionSheet: (o) =>
253
+ dual(
254
+ 'showActionSheet',
255
+ o,
256
+ (a) =>
257
+ new Promise((resolve, reject) => {
258
+ const items = (a.itemList || [])
259
+ .map(
260
+ (t, i) =>
261
+ `<button data-i="${i}" style="display:block;width:100%;padding:8px;margin:4px 0;text-align:left">${t}</button>`
262
+ )
263
+ .join('')
264
+ const box = overlay(
265
+ items +
266
+ `<button id="__x" style="display:block;width:100%;padding:8px;margin-top:8px;color:#888">取消</button>`
267
+ )
268
+ box.querySelectorAll('button[data-i]').forEach((b) => {
269
+ b.onclick = () => {
270
+ box.remove()
271
+ resolve({ tapIndex: Number(b.getAttribute('data-i')) })
272
+ }
273
+ })
274
+ box.querySelector('#__x').onclick = () => {
275
+ box.remove()
276
+ reject(new Error('cancel'))
277
+ }
278
+ })
279
+ ),
280
+ previewImage: (o) =>
281
+ dual('previewImage', o, (a) => {
282
+ const cur = a.current || (a.urls || [])[0]
283
+ overlay(
284
+ `<img src="${cur}" style="max-width:78vw;max-height:78vh;display:block"/>`
285
+ ).onclick = (e) => e.currentTarget.remove()
286
+ }),
287
+
288
+ // —— 启动 / 系统 ——
289
+ getLaunchOptionsSync: () => ({
290
+ path: '',
291
+ query: cfg.query || {},
292
+ scene: (cfg.query && cfg.query.scene) || 'panel'
293
+ }),
294
+ getEnterOptionsSync: () => ({
295
+ path: '',
296
+ query: cfg.query || {},
297
+ scene: (cfg.query && cfg.query.scene) || 'panel'
298
+ }),
299
+ getSystemInfoSync: () => ({
300
+ platform: 'workfly',
301
+ theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
302
+ version: cfg.version || 'dev',
303
+ language: navigator.language || 'zh-CN'
304
+ }),
305
+
306
+ // —— BIP 扩展 ——
307
+ bip: {
308
+ rsaEncrypt: (o) => dual('bip.rsaEncrypt', o, (a) => proxy('/__wf/rsa', a))
309
+ }
310
+ }
311
+
312
+ window.wf = wf
313
+ logCall('shim ready', true, `permissions: ${[...PERMS].join(', ') || '(无)'}`)
314
+ })()
package/src/shim.js ADDED
@@ -0,0 +1,30 @@
1
+ // dev 调试宿主的 wf shim 注入 —— 把浏览器端 shim(shim.client.js)与启动配置插进 HTML。
2
+ // 生产由 src/preload/miniapp.ts 的 contextBridge 注入 window.wf;dev 无 preload,故注入同名 shim。
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const CLIENT = fileURLToPath(new URL('./shim.client.js', import.meta.url))
7
+
8
+ /** 浏览器端 wf shim 源码文本(dev server / vite-plugin 以 /__wf/shim.js 提供)。 */
9
+ export function shimSource() {
10
+ return readFileSync(CLIENT, 'utf8')
11
+ }
12
+
13
+ /**
14
+ * 把 wf shim + 注入配置插进 <head>,shim 是同步脚本,先于 deferred module 跑。
15
+ * @param {string} html
16
+ * @param {string} configJson JSON.stringify 后的 __WF_CONFIG__
17
+ * @param {{ relaxConnect?: boolean }} [opts] dev 下放宽 CSP connect-src,让同源 /__wf/* fetch 通过
18
+ */
19
+ export function injectShimHtml(html, configJson, opts = {}) {
20
+ let out = html
21
+ if (opts.relaxConnect) out = relaxConnectSrc(out)
22
+ const inject = `<script>window.__WF_CONFIG__=${configJson}</script><script src="/__wf/shim.js"></script>`
23
+ if (out.includes('</head>')) return out.replace('</head>', `${inject}</head>`)
24
+ return inject + out
25
+ }
26
+
27
+ /** 把 CSP meta 里的 connect-src 放宽(仅 dev:沙盒 connect-src 'none' 会挡掉 shim 的同源代理 fetch)。 */
28
+ function relaxConnectSrc(html) {
29
+ return html.replace(/connect-src[^;"']*/gi, "connect-src 'self' http: https: ws: wss:")
30
+ }
@@ -0,0 +1,188 @@
1
+ // manifest.json 校验 —— 真相源:src/shared/app.ts,释义见 docs/miniapp-manifest-spec.md。
2
+ // 结构化返回 { report, manifest },不 console.log / process.exit;打印与中断由调用者决定。
3
+ import { readFileSync, existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ const SCOPES = ['private', 'team', 'org_market']
7
+ const CONTAINS = ['ui', 'skill', 'mcp', 'kb', 'workflow', 'backend']
8
+ const LAUNCHERS = [
9
+ 'miniprogram_panel',
10
+ 'chat_command',
11
+ 'chat_intent',
12
+ 'spotlight',
13
+ 'deeplink',
14
+ 'scheduler'
15
+ ]
16
+ const RENDERS = ['webview', 'sandbox', 'bundle']
17
+ const LAYOUTS = ['full', 'split']
18
+ const PERMISSIONS = ['net.fetch', 'vault', 'clipboard']
19
+ const MATCHES = ['curl', 'url', 'json']
20
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
21
+
22
+ /** 收集校验结果;errors 阻断打包,warnings 仅提示。 */
23
+ export class Report {
24
+ errors = []
25
+ warnings = []
26
+ err(msg) {
27
+ this.errors.push(msg)
28
+ }
29
+ warn(msg) {
30
+ this.warnings.push(msg)
31
+ }
32
+ }
33
+
34
+ function isObj(v) {
35
+ return v != null && typeof v === 'object' && !Array.isArray(v)
36
+ }
37
+
38
+ /** 校验 match ∈ 'curl'|'url'|'json'|{regex}。 */
39
+ function checkMatch(m, where, r) {
40
+ if (typeof m === 'string') {
41
+ if (!MATCHES.includes(m))
42
+ r.err(`${where}.match 取值 '${m}' 非法,应为 ${MATCHES.join('/')} 或 { regex }`)
43
+ } else if (isObj(m)) {
44
+ if (typeof m.regex !== 'string') r.err(`${where}.match 对象形态须为 { regex: string }`)
45
+ } else {
46
+ r.err(`${where}.match 缺失或类型错误`)
47
+ }
48
+ }
49
+
50
+ /** 校验 launch ∈ { deeplink: string }。 */
51
+ function checkLaunch(l, where, r) {
52
+ if (!isObj(l) || typeof l.deeplink !== 'string')
53
+ r.err(`${where}.launch 须为 { deeplink: string }`)
54
+ }
55
+
56
+ /**
57
+ * 校验某目录下的 manifest.json。
58
+ * @param {string} dir 工程目录
59
+ * @param {{ forPack?: boolean }} [opts] forPack:打包前校验,缺失的打包入口记 error 而非 warn
60
+ * @returns {{ report: Report, manifest: object | null }}
61
+ */
62
+ export function validateManifest(dir, opts = {}) {
63
+ const { forPack = false } = opts
64
+ const r = new Report()
65
+ const manifestPath = join(dir, 'manifest.json')
66
+ if (!existsSync(manifestPath)) {
67
+ r.err('缺少 manifest.json')
68
+ return { report: r, manifest: null }
69
+ }
70
+ let m
71
+ try {
72
+ m = JSON.parse(readFileSync(manifestPath, 'utf8'))
73
+ } catch (e) {
74
+ r.err(`manifest.json 不是合法 JSON:${e.message}`)
75
+ return { report: r, manifest: null }
76
+ }
77
+
78
+ // 身份与版本族
79
+ if (typeof m.id !== 'string' || !m.id.trim()) r.err('id 必填且为非空字符串')
80
+ if (typeof m.name !== 'string' || !m.name.trim()) r.err('name 必填且为非空字符串')
81
+ if (typeof m.version !== 'string' || !SEMVER.test(m.version)) r.err('version 必填且须为 SemVer')
82
+ if (m.manifest !== undefined && typeof m.manifest !== 'number') r.err('manifest 须为数字')
83
+ if (m.wfSdk !== undefined && !SEMVER.test(String(m.wfSdk))) r.err('wfSdk 须为 SemVer')
84
+ if (m.minClient !== undefined && !SEMVER.test(String(m.minClient))) r.err('minClient 须为 SemVer')
85
+ if (m.manifest === undefined) r.warn('未声明 manifest(清单格式版本),将按 1 处理;建议显式写明')
86
+ if (m.wfSdk === undefined) r.warn('未声明 wfSdk(面向的 SDK 版本),宿主不做兼容判定;建议写明')
87
+
88
+ // scope / contains
89
+ if (!SCOPES.includes(m.scope)) r.err(`scope 必填且 ∈ ${SCOPES.join('/')}`)
90
+ if (!Array.isArray(m.contains) || !m.contains.length) {
91
+ r.err('contains 必填且为非空数组')
92
+ } else {
93
+ for (const c of m.contains) if (!CONTAINS.includes(c)) r.err(`contains 含非法值 '${c}'`)
94
+ if (!m.contains.includes('ui')) r.warn("第三方小程序通常至少 contains 含 'ui'")
95
+ }
96
+
97
+ // ui.standalone
98
+ const s = m.ui?.standalone
99
+ if (!isObj(s)) {
100
+ r.err('ui.standalone 必填')
101
+ } else {
102
+ if (s.enabled !== true) r.err('ui.standalone.enabled 须为 true')
103
+ const render = s.render ?? 'webview'
104
+ if (!RENDERS.includes(render)) r.err(`ui.standalone.render ∈ ${RENDERS.join('/')}`)
105
+ if (s.layout !== undefined && !LAYOUTS.includes(s.layout))
106
+ r.err(`ui.standalone.layout ∈ ${LAYOUTS.join('/')}`)
107
+ if (typeof s.entry !== 'string' || !s.entry.trim()) {
108
+ r.err('ui.standalone.entry 必填')
109
+ } else if (render === 'webview') {
110
+ if (!/^https?:\/\//.test(s.entry)) r.warn('webview 形态 entry 应为 http(s) URL')
111
+ if (!isObj(m.session)) r.warn('webview 形态通常需配 session(cookie 持久化)')
112
+ } else if (!existsSync(join(dir, s.entry))) {
113
+ // sandbox / bundle:entry 是包内相对路径
114
+ const msg = `ui.standalone.entry 指向的文件不存在:${s.entry}${render === 'bundle' ? '(bundle 形态需先 build)' : ''}`
115
+ forPack ? r.err(msg) : r.warn(msg)
116
+ }
117
+ }
118
+
119
+ // launchers
120
+ if (!Array.isArray(m.launchers) || !m.launchers.length) {
121
+ r.err('launchers 必填且为非空数组')
122
+ } else {
123
+ for (const l of m.launchers) if (!LAUNCHERS.includes(l)) r.err(`launchers 含非法值 '${l}'`)
124
+ }
125
+
126
+ // permissions(兼容旧 string[] 形态)
127
+ if (m.permissions !== undefined) {
128
+ if (!Array.isArray(m.permissions)) {
129
+ r.err('permissions 须为数组')
130
+ } else {
131
+ for (const [i, p] of m.permissions.entries()) {
132
+ if (typeof p === 'string') {
133
+ if (!PERMISSIONS.includes(p)) r.err(`permissions[${i}] 非法能力 '${p}'`)
134
+ r.warn(`permissions[${i}] 用了旧字符串形态,建议改为 { id, reason }`)
135
+ } else if (isObj(p)) {
136
+ if (!PERMISSIONS.includes(p.id)) r.err(`permissions[${i}].id 非法能力 '${p.id}'`)
137
+ if (typeof p.reason !== 'string' || !p.reason.trim())
138
+ r.warn(`permissions[${i}].reason 缺失,安装授权弹窗将无说明`)
139
+ } else {
140
+ r.err(`permissions[${i}] 须为 { id, reason } 或能力字符串`)
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ // contributes
147
+ const ct = m.contributes
148
+ if (ct !== undefined) {
149
+ if (!isObj(ct)) {
150
+ r.err('contributes 须为对象')
151
+ } else {
152
+ for (const [i, h] of (ct.clipboardHints ?? []).entries()) {
153
+ checkMatch(h?.match, `contributes.clipboardHints[${i}]`, r)
154
+ if (typeof h?.label !== 'string') r.err(`contributes.clipboardHints[${i}].label 须为字符串`)
155
+ checkLaunch(h?.launch, `contributes.clipboardHints[${i}]`, r)
156
+ }
157
+ for (const [i, h] of (ct.contentMatchers ?? []).entries()) {
158
+ if (h?.surface !== 'im') r.err(`contributes.contentMatchers[${i}].surface 当前仅支持 'im'`)
159
+ checkMatch(h?.match, `contributes.contentMatchers[${i}]`, r)
160
+ if (typeof h?.label !== 'string')
161
+ r.err(`contributes.contentMatchers[${i}].label 须为字符串`)
162
+ checkLaunch(h?.launch, `contributes.contentMatchers[${i}]`, r)
163
+ }
164
+ for (const [i, c] of (ct.cardRenderers ?? []).entries()) {
165
+ if (typeof c?.kind !== 'string') r.err(`contributes.cardRenderers[${i}].kind 须为字符串`)
166
+ if (c?.mode !== 'sandbox')
167
+ r.err(`contributes.cardRenderers[${i}].mode 三方仅支持 'sandbox'`)
168
+ if (typeof c?.entry !== 'string') {
169
+ r.err(`contributes.cardRenderers[${i}].entry 须为字符串`)
170
+ } else if (!existsSync(join(dir, c.entry))) {
171
+ const msg = `contributes.cardRenderers[${i}].entry 文件不存在:${c.entry}`
172
+ forPack ? r.err(msg) : r.warn(msg)
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ // icon / iconPlaceholder
179
+ if (typeof m.icon === 'string' && !existsSync(join(dir, m.icon)))
180
+ r.warn(`icon 指向的文件不存在:${m.icon},将回退 iconPlaceholder`)
181
+ if (m.iconPlaceholder !== undefined) {
182
+ const ip = m.iconPlaceholder
183
+ if (!isObj(ip) || typeof ip.initial !== 'string' || typeof ip.color !== 'string')
184
+ r.err('iconPlaceholder 须为 { initial, color }')
185
+ }
186
+
187
+ return { report: r, manifest: m }
188
+ }