fde2e-bu 1.2.0 → 1.3.1

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/src/nm/host.mjs CHANGED
@@ -1,25 +1,42 @@
1
- // Native Messaging host(change bu-bind-http-publish A1/A2)——Chrome NM 帧协议
2
- // 与 bu:pair 配对消息处理。隐蔽子命令 `fde2e-bu __nm-host` 直通本模块 runNmHost。
1
+ // Native Messaging host(change bu-bind-http-publish A1/A2;bu-online-selfupdate A2
2
+ // 增 bu:update;bu-chrome154-downloads B1 增 bu:asset-fetch)——Chrome NM 帧协议与
3
+ // bu:pair/bu:update/bu:asset-fetch 消息处理。隐蔽子命令
4
+ // `fde2e-bu __nm-host` 直通本模块 runNmHost。
3
5
  //
4
6
  // 帧协议(Chrome Native Messaging 规范):
5
7
  // 4 字节小端长度前缀 + UTF-8 JSON 载荷;单帧上限 1MB(超限回错误帧,不崩不退出
6
8
  // ——长度前缀流无法重同步,按「4+声明长度」流式丢弃该帧字节后继续解后续帧)。
7
- // 消息(扩展 → CLI,唯一受支持类型):
9
+ // 消息(扩展 → CLI):
8
10
  // {type:'bu:pair', version:'<manifest version>',
9
11
  // bindings:[{profileId, role:'master'|'cluster', name?, server?}]}
10
12
  // (bindings 来自扩展绑定表快照;空数组合法——照常落盘并如实显示 0 条。)
13
+ // {type:'bu:update', feedVersion, url, sha256, size, runningVersion}
14
+ // (bu-online-selfupdate design C4:SW 判定有新版后请 host 定位 unpacked 安装并
15
+ // 下载校验原子替换;url 相对形态按官方 downloads 基址解析,域名硬白名单。)
16
+ // {type:'bu:asset-fetch', url, name, maxBytes, allowHosts}
17
+ // (bu-chrome154-downloads design §A:Chrome 154 chrome.downloads 即断——SW 配图
18
+ // 下载改 host 代取:逐跳白名单流式下载到 ~/fde2e/bu/assets/,回绝对路径+sha256。)
11
19
  // 应答(CLI → 扩展):
12
20
  // 成功 {type:'bu:pair:ok', cli:'fde2e-bu', version:<pkg>, saved:N}
21
+ // {type:'bu:update:ok', from, to, paths:[…]}(多命中全部应用)
22
+ // {type:'bu:asset-fetch:ok', path, bytes, finalUrl, sha256}
13
23
  // 失败 {type:'bu:pair:error', message:<原因>}(未知类型/校验不过/落盘失败)
24
+ // {type:'bu:update:error', code, message, paths?}
25
+ // (code ∈ managed|not-found|verify|bad-url|io|bad-msg——面板按码映射文案)
26
+ // {type:'bu:asset-fetch:error', code, message}
27
+ // (code ∈ bad-msg|bad-url|not-allowed|too-large|net|io——见 nm/asset.mjs)
14
28
  // 落盘:pairing.json({version, savedAt, bindings},atomicWrite 0600——复用
15
- // src/mcp/config.mjs 的原子写底座)。pairing 只含 profileId/role/name/server 等
16
- // 非敏感标识,绝不含 token/credential。
29
+ // src/mcp/config.mjs 的原子写底座)。pairing 只含 profileId/role/name/server 等
30
+ // 非敏感标识,绝不含 token/credential。资产代取落盘=~/fde2e/bu/assets/(nm/asset.mjs)。
17
31
  // 生命周期:stdin EOF → 退出 0;一切日志只走 stderr(stdout 只有协议帧)。
18
32
 
19
33
  import { existsSync, readFileSync } from 'node:fs';
34
+ import { homedir } from 'node:os';
20
35
  import { join } from 'node:path';
21
36
  import { atomicWrite } from '../mcp/config.mjs';
22
37
  import { resolveConfigDir } from '../configdir.mjs';
38
+ import { locateInstalls, applyUpdate, EXTENSION_ID, resolveNmUrl } from './update.mjs';
39
+ import { assetFetchHost } from './asset.mjs';
23
40
  import pkg from '../../package.json' with { type: 'json' };
24
41
 
25
42
  export const NM_HOST_NAME = 'com.fde2e.bu';
@@ -166,6 +183,112 @@ export function validatePairMessage(msg) {
166
183
  return { ok: true, bindings };
167
184
  }
168
185
 
186
+ // ---------- bu:update 校验与处理(bu-online-selfupdate A2,design C4) ----------
187
+
188
+ /**
189
+ * 校验 bu:update 消息形状:{type, feedVersion, url, sha256, size, runningVersion}
190
+ * 六字段齐备(url/sha256/size 后续在下载层再深校验——此处先拒结构性畸形)。
191
+ * @returns {{ ok:true } | { ok:false, message:string }}
192
+ */
193
+ export function validateUpdateMessage(msg) {
194
+ if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) {
195
+ return { ok: false, message: '消息须为 JSON 对象' };
196
+ }
197
+ if (msg.type !== 'bu:update') {
198
+ return { ok: false, message: `未知消息类型:${JSON.stringify(msg.type ?? null)}` };
199
+ }
200
+ for (const k of ['feedVersion', 'url', 'runningVersion']) {
201
+ if (typeof msg[k] !== 'string' || msg[k] === '') {
202
+ return { ok: false, message: `bu:update.${k} 须为非空字符串(收到 ${JSON.stringify(msg[k] ?? null)})` };
203
+ }
204
+ }
205
+ if (typeof msg.sha256 !== 'string' || !/^[0-9a-fA-F]{64}$/.test(msg.sha256)) {
206
+ return { ok: false, message: 'bu:update.sha256 须为 64 位 hex 字符串' };
207
+ }
208
+ if (!Number.isSafeInteger(msg.size) || msg.size <= 0) {
209
+ return { ok: false, message: `bu:update.size 须为正整数(收到 ${JSON.stringify(msg.size ?? null)})` };
210
+ }
211
+ return { ok: true };
212
+ }
213
+
214
+ function updateErrorReply(code, message, paths) {
215
+ return paths === undefined
216
+ ? { type: 'bu:update:error', code, message }
217
+ : { type: 'bu:update:error', code, message, paths };
218
+ }
219
+
220
+ /**
221
+ * 处理 bu:update(async——下载/替换是异步动作):locateInstalls 定位 → 受管拒更 →
222
+ * 多命中全部应用(同一 zip 逐一 applyUpdate;任一失败聚合 error,已成功的不回滚、
223
+ * paths 如实全列)。依赖全注入可测(home/platform/fetch/sleep/env)。
224
+ * @param {{ home?: string, platform?: string, extId?: string,
225
+ * fetchImpl?: Function, sleepImpl?: Function, env?: object }} [deps]
226
+ * @returns {Promise<{type:'bu:update:ok', from, to, paths}
227
+ * |{type:'bu:update:error', code, message, paths?}>}
228
+ */
229
+ export async function handleUpdateMessage(msg, deps = {}) {
230
+ const check = validateUpdateMessage(msg);
231
+ if (!check.ok) return updateErrorReply('bad-msg', check.message);
232
+ try {
233
+ resolveNmUrl(msg.url, deps.env ?? process.env); // 白名单前置:坏 url 先拒(省一次定位)
234
+ } catch (err) {
235
+ return updateErrorReply('bad-url', err?.message ?? 'url 非法');
236
+ }
237
+ let located;
238
+ try {
239
+ located = locateInstalls({
240
+ home: deps.home ?? homedir(),
241
+ platform: deps.platform,
242
+ extId: deps.extId ?? EXTENSION_ID,
243
+ runningVersion: msg.runningVersion,
244
+ });
245
+ } catch (err) {
246
+ return updateErrorReply('io', `安装定位失败:${err?.message ?? err}`);
247
+ }
248
+ if (located.matches.length === 0) {
249
+ if (located.managed.length > 0) {
250
+ // 全部命中都是受管安装 → 让路 omni App(proposal 硬约束 1)
251
+ return updateErrorReply('managed', '安装由 FDE2E Omni 桌面版管理,插件随 App 更新', located.managed.map((m) => m.path));
252
+ }
253
+ const clue = located.rejected.length > 0
254
+ ? `(发现 ${located.rejected.length} 处版本不符的旧拷贝:${located.rejected.map((r) => `${r.path}(v${r.found}≠v${r.expected})`).join(';')})`
255
+ : '';
256
+ return updateErrorReply('not-found', `未发现运行 v${msg.runningVersion} 的 unpacked 安装${clue}`);
257
+ }
258
+ const results = [];
259
+ const failures = [];
260
+ for (const m of located.matches) {
261
+ try {
262
+ results.push(await applyUpdate({
263
+ url: msg.url, sha256: msg.sha256, size: msg.size, destDir: m.path,
264
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
265
+ ...(deps.sleepImpl ? { sleepImpl: deps.sleepImpl } : {}),
266
+ ...(deps.env ? { env: deps.env } : {}),
267
+ }));
268
+ } catch (err) {
269
+ failures.push({ path: m.path, code: ['verify', 'bad-url', 'io'].includes(err?.code) ? err.code : 'io', message: err?.message ?? String(err) });
270
+ }
271
+ }
272
+ if (failures.length > 0) {
273
+ // 聚合报错但已成功的不回滚:paths 如实列出全部(成功在前、失败在后)
274
+ return updateErrorReply(
275
+ failures[0].code,
276
+ `已应用 ${results.length}/${located.matches.length},失败:${failures.map((f) => `${f.path}(${f.code}:${f.message})`).join(';')}`,
277
+ [...results.map((r) => r.path), ...failures.map((f) => f.path)],
278
+ );
279
+ }
280
+ return {
281
+ type: 'bu:update:ok',
282
+ from: results[0]?.from ?? msg.runningVersion,
283
+ to: results[0]?.to ?? msg.feedVersion,
284
+ paths: results.map((r) => r.path),
285
+ // 部分受管时如实让面板知情(对抗审查 P2.4——静默跳过会让「全成功」失真)
286
+ ...(located.managed.length > 0
287
+ ? { skippedManaged: located.managed.map((m) => m.path) }
288
+ : {}),
289
+ };
290
+ }
291
+
169
292
  // ---------- 消息处理(依赖注入,可测) ----------
170
293
 
171
294
  function errorReply(message) {
@@ -174,17 +297,36 @@ function errorReply(message) {
174
297
 
175
298
  /**
176
299
  * 单条 NM 消息 → 应答对象(不碰流;落盘经 deps 注入,纯逻辑可测)。
300
+ * bu:update 走异步 handleUpdateMessage、bu:asset-fetch 走异步 assetFetchHost
301
+ * (返回 Promise——runNmHost 的写队列统一序列化应答帧);其余(bu:pair/未知类型)
302
+ * 同步应答,行为不变。
177
303
  * @param {unknown} msg 解析后的 JSON 值
178
- * @param {{ version?: string, now?: () => string, save?: (pairing:object) => void }} [deps]
304
+ * @param {{ version?: string, now?: () => string, save?: (pairing:object) => void,
305
+ * home?: string, platform?: string, extId?: string,
306
+ * fetchImpl?: Function, sleepImpl?: Function, env?: object,
307
+ * fs?: object, sweepTtlMs?: number }} [deps]
179
308
  * save 注入落盘动作;缺省=写 resolveConfigDir() 下 pairing.json(调用时才解析
180
- * env/家目录——测试可用 BU_CONFIG_DIR 重定向)。
309
+ * env/家目录——测试可用 BU_CONFIG_DIR 重定向)。home/platform/fetchImpl/
310
+ * sleepImpl/env 属 bu:update 通道(定位与下载注入);home/fs/fetchImpl/now/
311
+ * sweepTtlMs 属 bu:asset-fetch 通道(资产目录与下载注入)。
181
312
  * @returns {{type:'bu:pair:ok', cli:string, version:string, saved:number,
182
313
  * received:number, truncated:boolean}
183
- * |{type:'bu:pair:error', message:string}}
314
+ * |{type:'bu:pair:error', message:string}
315
+ * |Promise<{type:'bu:update:ok'|'bu:update:error'|'bu:asset-fetch:ok'
316
+ * |'bu:asset-fetch:error', …}>}
184
317
  * P1-2:received=收到的原始条数、truncated=扩展侧是否声明截断——截断要如实
185
318
  * 回执给 UI 显示「K 条未同步」,不再只报 saved 装作全量。
186
319
  */
187
320
  export function handleNmMessage(msg, deps = {}) {
321
+ // bu:update 分流(异步通道):形状校验/定位/下载全在 handleUpdateMessage 内
322
+ if (msg !== null && typeof msg === 'object' && !Array.isArray(msg) && msg.type === 'bu:update') {
323
+ return handleUpdateMessage(msg, deps);
324
+ }
325
+ // bu:asset-fetch 分流(异步通道,bu-chrome154-downloads B1):校验/逐跳白名单
326
+ // 下载/原子落盘全在 assetFetchHost 内;bu:pair/bu:update 行为零变更
327
+ if (msg !== null && typeof msg === 'object' && !Array.isArray(msg) && msg.type === 'bu:asset-fetch') {
328
+ return assetFetchHost(msg, deps);
329
+ }
188
330
  const version = deps.version ?? pkg.version;
189
331
  const check = validatePairMessage(msg);
190
332
  if (!check.ok) return errorReply(check.message);
@@ -235,6 +377,21 @@ export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stde
235
377
  stderr.write(`nm-host: 应答帧写出失败:${err?.message ?? err}\n`);
236
378
  }
237
379
  };
380
+ // A2(bu-online-selfupdate):应答帧统一经写队列串行化——bu:update 的应答是
381
+ // 异步产物(下载/替换),与后续消息的同步应答必须保序;同步应答只多等一个
382
+ // microtask,对端无感。处理异常(含 Promise 拒绝)兜底回 bu:pair:error 帧。
383
+ let writeChain = Promise.resolve();
384
+ const enqueueReply = (produce) => {
385
+ writeChain = writeChain.then(
386
+ () => Promise.resolve().then(produce).then(writeFrame, (err) => {
387
+ stderr.write(`nm-host: 消息处理异常:${err?.message ?? err}\n`);
388
+ writeFrame(errorReply(`处理异常:${err?.message ?? err}`));
389
+ }),
390
+ (err) => {
391
+ stderr.write(`nm-host: 应答写出失败:${err?.message ?? err}\n`);
392
+ },
393
+ );
394
+ };
238
395
  let buf = Buffer.alloc(0);
239
396
  // P2-1:超限帧流式丢弃——不清空整个缓冲(那会把同批到达的后续合法帧一起杀
240
397
  // 掉),改为按「4+声明长度」记账,随到达字节扣减;扣完即恢复常规解帧。
@@ -258,7 +415,7 @@ export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stde
258
415
  const r = decodeFrame(buf);
259
416
  if (r.error === 'too-large') {
260
417
  stderr.write(`nm-host: 帧超限(声明 ${r.declared}B > ${MAX_FRAME_BYTES}B)——流式丢弃 ${4 + r.declared}B\n`);
261
- writeFrame(errorReply(`帧超限:声明 ${r.declared}B > 上限 ${MAX_FRAME_BYTES}B`));
418
+ enqueueReply(() => errorReply(`帧超限:声明 ${r.declared}B > 上限 ${MAX_FRAME_BYTES}B`));
262
419
  const total = 4 + r.declared;
263
420
  const take = Math.min(buf.length, total);
264
421
  discard = total - take;
@@ -273,15 +430,10 @@ export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stde
273
430
  msg = JSON.parse(r.payload);
274
431
  } catch {
275
432
  stderr.write('nm-host: 帧载荷不是合法 JSON\n');
276
- writeFrame(errorReply('帧载荷不是合法 JSON'));
433
+ enqueueReply(() => errorReply('帧载荷不是合法 JSON'));
277
434
  continue;
278
435
  }
279
- try {
280
- writeFrame(handleNmMessage(msg, deps));
281
- } catch (err) {
282
- stderr.write(`nm-host: 消息处理异常:${err?.message ?? err}\n`);
283
- writeFrame(errorReply(`处理异常:${err?.message ?? err}`));
284
- }
436
+ enqueueReply(() => handleNmMessage(msg, deps));
285
437
  }
286
438
  });
287
439
  stdin.on('end', () => finish(0)); // 客户端断开(Chrome 端 port.disconnect)→ 干净退出 0