fde2e-bu 1.2.0 → 1.3.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/package.json +1 -1
- package/src/cli.mjs +58 -9
- package/src/misc.mjs +2 -14
- package/src/nm/host.mjs +151 -16
- package/src/nm/update.mjs +727 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -14,8 +14,9 @@ import { runServe } from './mcp/serve.mjs';
|
|
|
14
14
|
import { runHttpServe, DEFAULT_HTTP_PORT } from './mcp/http.mjs';
|
|
15
15
|
import { runNmHost } from './nm/host.mjs';
|
|
16
16
|
import { cmdBindInstall, cmdBindStatus, cmdBindRemove, assertBindPlatform } from './nm/bind.mjs';
|
|
17
|
+
import { cmdSelfUpdate, DEFAULT_FEED_URL } from './nm/update.mjs';
|
|
17
18
|
import { resolveConfigDir } from './configdir.mjs';
|
|
18
|
-
import { cmdStatus, cmdDoctor
|
|
19
|
+
import { cmdStatus, cmdDoctor } from './misc.mjs';
|
|
19
20
|
import { readFileSync } from 'node:fs';
|
|
20
21
|
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { dirname, join } from 'node:path';
|
|
@@ -35,7 +36,8 @@ const MAIN_HELP = `fde2e-bu —— FDE2E Browser Use 命令行(零运行时依
|
|
|
35
36
|
serve(stdio JSON-RPC 2.0;--http=仅回环 token 端点)| add | list | remove
|
|
36
37
|
status 桥服务(omni /healthz)健康探测,离线降级提示;顶部显示已配对执行器
|
|
37
38
|
doctor 自检:node 版本 / 配置路径 / 环境变量 / 扩展 zip / NM manifest / pairing
|
|
38
|
-
update
|
|
39
|
+
update 扩展自更新:发现 unpacked 安装 → feed 检查 → 下载校验原子替换
|
|
40
|
+
[--ext-dir <dir>] [--feed <url>] [--dry-run]
|
|
39
41
|
bind 扩展↔CLI 一次性配对(Chrome Native Messaging)
|
|
40
42
|
install | status | remove
|
|
41
43
|
|
|
@@ -112,6 +114,29 @@ flag 面按子命令收窄(C1):不适用 flag 与多余位置参数一律
|
|
|
112
114
|
(如 mcp list --profile-id x 不再被静默忽略);--port/--token 仅 --http 模式可用。
|
|
113
115
|
--home/--dir 显式重定向配置根与工作区根(测试用,不碰真实家目录)。`;
|
|
114
116
|
|
|
117
|
+
const UPDATE_HELP = `fde2e-bu update —— 扩展自更新(unpacked 独立安装)
|
|
118
|
+
|
|
119
|
+
用法:
|
|
120
|
+
fde2e-bu update [--ext-dir <dir>] [--feed <url>] [--dry-run]
|
|
121
|
+
发现本机 unpacked 安装(Chrome 系浏览器 Preferences/Secure Preferences 的
|
|
122
|
+
extensions.settings[meojillndgamkbfjijfehjaefolnfopn].path,多浏览器多
|
|
123
|
+
profile 全扫)→ fetch feed(缺省 ${DEFAULT_FEED_URL})→ semver 只认 > →
|
|
124
|
+
下载(https 强制)→ sha256/字节数双校验 → staging 解压(zip-slip/条目数/
|
|
125
|
+
根 manifest/总量四重防护)→ rm+rename 原子替换(Windows 瞬态占用退避)。
|
|
126
|
+
多处安装全部应用;任一失败不回滚其它。受 FDE2E Omni 桌面版管理的安装
|
|
127
|
+
(~/fde2e/bu/ 内或目录旁 .browser-extension-state.json)自动跳过——随 App 更新。
|
|
128
|
+
--ext-dir <dir> 显式指定安装目录(复核 manifest.json 在场即可,免扫描)
|
|
129
|
+
--feed <url> 覆写 feed 地址(测试/私有部署;仍强制 https)
|
|
130
|
+
--dry-run 只报发现与目标版本,不写盘
|
|
131
|
+
0 命中 → exit 1+指引(浏览器未开过本插件/装在非常规处时用 --ext-dir)。
|
|
132
|
+
CLI 本体的更新仍走 npm:npm i -g fde2e-bu@latest。
|
|
133
|
+
|
|
134
|
+
环境变量(NM host 路径也生效):
|
|
135
|
+
BU_UPDATE_BASE 下载基址/白名单重定向(测试假 feed server/私有部署;
|
|
136
|
+
信任重定向到指定基址,不是放宽为任意域)
|
|
137
|
+
|
|
138
|
+
设计:openspec/changes/bu-online-selfupdate(proposal/design C)。`;
|
|
139
|
+
|
|
115
140
|
const BIND_HELP = `fde2e-bu bind —— 扩展↔CLI 一次性配对(Chrome Native Messaging)
|
|
116
141
|
|
|
117
142
|
平台:支持 macOS 与 Linux(NM 清单按 Chrome 规范路径文件落盘);Windows 需注册表
|
|
@@ -296,18 +321,42 @@ export async function main(argv) {
|
|
|
296
321
|
return cmdDoctor({ home: values.home, dir: values.dir, json: values.json, configDir: values['config-dir'], manifestsDir: values['manifests-dir'] });
|
|
297
322
|
}
|
|
298
323
|
case 'update': {
|
|
299
|
-
//
|
|
300
|
-
|
|
324
|
+
// A3(bu-online-selfupdate):--ext-dir/--feed/--dry-run;不适用 flag 与多余
|
|
325
|
+
// 位置参数一律 exit 2(cli-tooling 规格);--feed 在参数面即强制 https。
|
|
326
|
+
assertFlags(rest, ['ext-dir', 'feed', 'dry-run', 'help'], 'update', { help: UPDATE_HELP });
|
|
301
327
|
const { positionals, values } = parse(rest, {
|
|
302
|
-
options: {
|
|
303
|
-
|
|
328
|
+
options: {
|
|
329
|
+
'ext-dir': { type: 'string' },
|
|
330
|
+
feed: { type: 'string' },
|
|
331
|
+
'dry-run': { type: 'boolean' },
|
|
332
|
+
help: { type: 'boolean', short: 'h' },
|
|
333
|
+
},
|
|
334
|
+
help: UPDATE_HELP,
|
|
304
335
|
});
|
|
305
336
|
if (values.help) {
|
|
306
|
-
console.log(
|
|
337
|
+
console.log(UPDATE_HELP);
|
|
307
338
|
return 0;
|
|
308
339
|
}
|
|
309
|
-
assertNoPositionals(positionals, 'update', { help:
|
|
310
|
-
|
|
340
|
+
assertNoPositionals(positionals, 'update', { help: UPDATE_HELP });
|
|
341
|
+
if (values['ext-dir'] !== undefined && values['ext-dir'] === '') {
|
|
342
|
+
throw new UsageError('--ext-dir 不能为空', { help: UPDATE_HELP });
|
|
343
|
+
}
|
|
344
|
+
if (values.feed !== undefined) {
|
|
345
|
+
let u;
|
|
346
|
+
try {
|
|
347
|
+
u = new URL(values.feed);
|
|
348
|
+
} catch {
|
|
349
|
+
throw new UsageError(`--feed url 无法解析:${values.feed}`, { help: UPDATE_HELP });
|
|
350
|
+
}
|
|
351
|
+
if (u.protocol !== 'https:') {
|
|
352
|
+
throw new UsageError(`--feed 必须 https(收到 ${u.protocol}//${u.host})`, { help: UPDATE_HELP });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return cmdSelfUpdate({
|
|
356
|
+
...(values['ext-dir'] !== undefined ? { extDir: values['ext-dir'] } : {}),
|
|
357
|
+
...(values.feed !== undefined ? { feed: values.feed } : {}),
|
|
358
|
+
dryRun: Boolean(values['dry-run']),
|
|
359
|
+
});
|
|
311
360
|
}
|
|
312
361
|
case 'bind': {
|
|
313
362
|
// A3:bind install/status/remove;--help/-h → BIND_HELP exit 0;裸 bind → 帮助 exit 2
|
package/src/misc.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// status / doctor
|
|
1
|
+
// status / doctor(tasks 1.2 / 1.3;C2 P2-7;A5=pairing 消费)。
|
|
2
|
+
// (update 组自 bu-online-selfupdate A3 起改走 src/nm/update.mjs 的 cmdSelfUpdate。)
|
|
2
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
3
4
|
import { join, dirname, resolve } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
@@ -8,7 +9,6 @@ import { findSkillsWorkspace } from './skills/skills.mjs';
|
|
|
8
9
|
import { resolveConfigDir } from './configdir.mjs';
|
|
9
10
|
import { readPairing, defaultProfileId } from './nm/host.mjs';
|
|
10
11
|
import { manifestTargets, readManifest, hostScriptExecutable } from './nm/bind.mjs';
|
|
11
|
-
import pkg from '../package.json' with { type: 'json' }; // P1-3:update 文案版本真值单源
|
|
12
12
|
|
|
13
13
|
// ---------- status(task 1.2 + C2):/healthz 探活+绑定配置面+六落点注册清单 ----------
|
|
14
14
|
|
|
@@ -227,15 +227,3 @@ export async function cmdDoctor({ home, dir, json, fetchImpl, healthzTimeoutMs,
|
|
|
227
227
|
}
|
|
228
228
|
return counts.fail > 0 ? 1 : 0;
|
|
229
229
|
}
|
|
230
|
-
|
|
231
|
-
// ---------- update(task 4.2):尊重 npm,不自作下载器 ----------
|
|
232
|
-
|
|
233
|
-
/** P1-3:版本真值取 package.json(不再写死 0.1.0/「尚未发布」——发布与否是
|
|
234
|
-
* registry 侧事实,CLI 只如实报当前版本与更新命令,404 语义交给用户对照)。 */
|
|
235
|
-
export function cmdUpdate() {
|
|
236
|
-
console.log(`当前 fde2e-bu@${pkg.version};更新走 npm(不自作下载器):`);
|
|
237
|
-
console.log(' npm i -g fde2e-bu@latest # 全局安装形态');
|
|
238
|
-
console.log(' npx -y fde2e-bu@latest … # 免装直跑(生产建议 pin 版本)');
|
|
239
|
-
console.log('若 npm registry 报 404(E404/PACKAGE_VERSION_NOT_FOUND),说明该版本尚未发布——属正常现象,等发布即可。');
|
|
240
|
-
return 0;
|
|
241
|
-
}
|
package/src/nm/host.mjs
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
|
-
// Native Messaging host(change bu-bind-http-publish A1/A2
|
|
2
|
-
//
|
|
1
|
+
// Native Messaging host(change bu-bind-http-publish A1/A2;bu-online-selfupdate A2
|
|
2
|
+
// 增 bu:update)——Chrome NM 帧协议与 bu:pair/bu:update 消息处理。隐蔽子命令
|
|
3
|
+
// `fde2e-bu __nm-host` 直通本模块 runNmHost。
|
|
3
4
|
//
|
|
4
5
|
// 帧协议(Chrome Native Messaging 规范):
|
|
5
6
|
// 4 字节小端长度前缀 + UTF-8 JSON 载荷;单帧上限 1MB(超限回错误帧,不崩不退出
|
|
6
7
|
// ——长度前缀流无法重同步,按「4+声明长度」流式丢弃该帧字节后继续解后续帧)。
|
|
7
|
-
// 消息(扩展 → CLI
|
|
8
|
+
// 消息(扩展 → CLI):
|
|
8
9
|
// {type:'bu:pair', version:'<manifest version>',
|
|
9
10
|
// bindings:[{profileId, role:'master'|'cluster', name?, server?}]}
|
|
10
11
|
// (bindings 来自扩展绑定表快照;空数组合法——照常落盘并如实显示 0 条。)
|
|
12
|
+
// {type:'bu:update', feedVersion, url, sha256, size, runningVersion}
|
|
13
|
+
// (bu-online-selfupdate design C4:SW 判定有新版后请 host 定位 unpacked 安装并
|
|
14
|
+
// 下载校验原子替换;url 相对形态按官方 downloads 基址解析,域名硬白名单。)
|
|
11
15
|
// 应答(CLI → 扩展):
|
|
12
16
|
// 成功 {type:'bu:pair:ok', cli:'fde2e-bu', version:<pkg>, saved:N}
|
|
17
|
+
// {type:'bu:update:ok', from, to, paths:[…]}(多命中全部应用)
|
|
13
18
|
// 失败 {type:'bu:pair:error', message:<原因>}(未知类型/校验不过/落盘失败)
|
|
19
|
+
// {type:'bu:update:error', code, message, paths?}
|
|
20
|
+
// (code ∈ managed|not-found|verify|bad-url|io|bad-msg——面板按码映射文案)
|
|
14
21
|
// 落盘:pairing.json({version, savedAt, bindings},atomicWrite 0600——复用
|
|
15
|
-
//
|
|
16
|
-
//
|
|
22
|
+
// src/mcp/config.mjs 的原子写底座)。pairing 只含 profileId/role/name/server 等
|
|
23
|
+
// 非敏感标识,绝不含 token/credential。
|
|
17
24
|
// 生命周期:stdin EOF → 退出 0;一切日志只走 stderr(stdout 只有协议帧)。
|
|
18
25
|
|
|
19
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
20
28
|
import { join } from 'node:path';
|
|
21
29
|
import { atomicWrite } from '../mcp/config.mjs';
|
|
22
30
|
import { resolveConfigDir } from '../configdir.mjs';
|
|
31
|
+
import { locateInstalls, applyUpdate, EXTENSION_ID, resolveNmUrl } from './update.mjs';
|
|
23
32
|
import pkg from '../../package.json' with { type: 'json' };
|
|
24
33
|
|
|
25
34
|
export const NM_HOST_NAME = 'com.fde2e.bu';
|
|
@@ -166,6 +175,112 @@ export function validatePairMessage(msg) {
|
|
|
166
175
|
return { ok: true, bindings };
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
// ---------- bu:update 校验与处理(bu-online-selfupdate A2,design C4) ----------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 校验 bu:update 消息形状:{type, feedVersion, url, sha256, size, runningVersion}
|
|
182
|
+
* 六字段齐备(url/sha256/size 后续在下载层再深校验——此处先拒结构性畸形)。
|
|
183
|
+
* @returns {{ ok:true } | { ok:false, message:string }}
|
|
184
|
+
*/
|
|
185
|
+
export function validateUpdateMessage(msg) {
|
|
186
|
+
if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) {
|
|
187
|
+
return { ok: false, message: '消息须为 JSON 对象' };
|
|
188
|
+
}
|
|
189
|
+
if (msg.type !== 'bu:update') {
|
|
190
|
+
return { ok: false, message: `未知消息类型:${JSON.stringify(msg.type ?? null)}` };
|
|
191
|
+
}
|
|
192
|
+
for (const k of ['feedVersion', 'url', 'runningVersion']) {
|
|
193
|
+
if (typeof msg[k] !== 'string' || msg[k] === '') {
|
|
194
|
+
return { ok: false, message: `bu:update.${k} 须为非空字符串(收到 ${JSON.stringify(msg[k] ?? null)})` };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (typeof msg.sha256 !== 'string' || !/^[0-9a-fA-F]{64}$/.test(msg.sha256)) {
|
|
198
|
+
return { ok: false, message: 'bu:update.sha256 须为 64 位 hex 字符串' };
|
|
199
|
+
}
|
|
200
|
+
if (!Number.isSafeInteger(msg.size) || msg.size <= 0) {
|
|
201
|
+
return { ok: false, message: `bu:update.size 须为正整数(收到 ${JSON.stringify(msg.size ?? null)})` };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function updateErrorReply(code, message, paths) {
|
|
207
|
+
return paths === undefined
|
|
208
|
+
? { type: 'bu:update:error', code, message }
|
|
209
|
+
: { type: 'bu:update:error', code, message, paths };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 处理 bu:update(async——下载/替换是异步动作):locateInstalls 定位 → 受管拒更 →
|
|
214
|
+
* 多命中全部应用(同一 zip 逐一 applyUpdate;任一失败聚合 error,已成功的不回滚、
|
|
215
|
+
* paths 如实全列)。依赖全注入可测(home/platform/fetch/sleep/env)。
|
|
216
|
+
* @param {{ home?: string, platform?: string, extId?: string,
|
|
217
|
+
* fetchImpl?: Function, sleepImpl?: Function, env?: object }} [deps]
|
|
218
|
+
* @returns {Promise<{type:'bu:update:ok', from, to, paths}
|
|
219
|
+
* |{type:'bu:update:error', code, message, paths?}>}
|
|
220
|
+
*/
|
|
221
|
+
export async function handleUpdateMessage(msg, deps = {}) {
|
|
222
|
+
const check = validateUpdateMessage(msg);
|
|
223
|
+
if (!check.ok) return updateErrorReply('bad-msg', check.message);
|
|
224
|
+
try {
|
|
225
|
+
resolveNmUrl(msg.url, deps.env ?? process.env); // 白名单前置:坏 url 先拒(省一次定位)
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return updateErrorReply('bad-url', err?.message ?? 'url 非法');
|
|
228
|
+
}
|
|
229
|
+
let located;
|
|
230
|
+
try {
|
|
231
|
+
located = locateInstalls({
|
|
232
|
+
home: deps.home ?? homedir(),
|
|
233
|
+
platform: deps.platform,
|
|
234
|
+
extId: deps.extId ?? EXTENSION_ID,
|
|
235
|
+
runningVersion: msg.runningVersion,
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
return updateErrorReply('io', `安装定位失败:${err?.message ?? err}`);
|
|
239
|
+
}
|
|
240
|
+
if (located.matches.length === 0) {
|
|
241
|
+
if (located.managed.length > 0) {
|
|
242
|
+
// 全部命中都是受管安装 → 让路 omni App(proposal 硬约束 1)
|
|
243
|
+
return updateErrorReply('managed', '安装由 FDE2E Omni 桌面版管理,插件随 App 更新', located.managed.map((m) => m.path));
|
|
244
|
+
}
|
|
245
|
+
const clue = located.rejected.length > 0
|
|
246
|
+
? `(发现 ${located.rejected.length} 处版本不符的旧拷贝:${located.rejected.map((r) => `${r.path}(v${r.found}≠v${r.expected})`).join(';')})`
|
|
247
|
+
: '';
|
|
248
|
+
return updateErrorReply('not-found', `未发现运行 v${msg.runningVersion} 的 unpacked 安装${clue}`);
|
|
249
|
+
}
|
|
250
|
+
const results = [];
|
|
251
|
+
const failures = [];
|
|
252
|
+
for (const m of located.matches) {
|
|
253
|
+
try {
|
|
254
|
+
results.push(await applyUpdate({
|
|
255
|
+
url: msg.url, sha256: msg.sha256, size: msg.size, destDir: m.path,
|
|
256
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
257
|
+
...(deps.sleepImpl ? { sleepImpl: deps.sleepImpl } : {}),
|
|
258
|
+
...(deps.env ? { env: deps.env } : {}),
|
|
259
|
+
}));
|
|
260
|
+
} catch (err) {
|
|
261
|
+
failures.push({ path: m.path, code: ['verify', 'bad-url', 'io'].includes(err?.code) ? err.code : 'io', message: err?.message ?? String(err) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (failures.length > 0) {
|
|
265
|
+
// 聚合报错但已成功的不回滚:paths 如实列出全部(成功在前、失败在后)
|
|
266
|
+
return updateErrorReply(
|
|
267
|
+
failures[0].code,
|
|
268
|
+
`已应用 ${results.length}/${located.matches.length},失败:${failures.map((f) => `${f.path}(${f.code}:${f.message})`).join(';')}`,
|
|
269
|
+
[...results.map((r) => r.path), ...failures.map((f) => f.path)],
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
type: 'bu:update:ok',
|
|
274
|
+
from: results[0]?.from ?? msg.runningVersion,
|
|
275
|
+
to: results[0]?.to ?? msg.feedVersion,
|
|
276
|
+
paths: results.map((r) => r.path),
|
|
277
|
+
// 部分受管时如实让面板知情(对抗审查 P2.4——静默跳过会让「全成功」失真)
|
|
278
|
+
...(located.managed.length > 0
|
|
279
|
+
? { skippedManaged: located.managed.map((m) => m.path) }
|
|
280
|
+
: {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
169
284
|
// ---------- 消息处理(依赖注入,可测) ----------
|
|
170
285
|
|
|
171
286
|
function errorReply(message) {
|
|
@@ -174,17 +289,27 @@ function errorReply(message) {
|
|
|
174
289
|
|
|
175
290
|
/**
|
|
176
291
|
* 单条 NM 消息 → 应答对象(不碰流;落盘经 deps 注入,纯逻辑可测)。
|
|
292
|
+
* bu:update 走异步 handleUpdateMessage(返回 Promise——runNmHost 的写队列统一
|
|
293
|
+
* 序列化应答帧);其余(bu:pair/未知类型)同步应答,行为不变。
|
|
177
294
|
* @param {unknown} msg 解析后的 JSON 值
|
|
178
|
-
* @param {{ version?: string, now?: () => string, save?: (pairing:object) => void
|
|
295
|
+
* @param {{ version?: string, now?: () => string, save?: (pairing:object) => void,
|
|
296
|
+
* home?: string, platform?: string, extId?: string,
|
|
297
|
+
* fetchImpl?: Function, sleepImpl?: Function, env?: object }} [deps]
|
|
179
298
|
* save 注入落盘动作;缺省=写 resolveConfigDir() 下 pairing.json(调用时才解析
|
|
180
|
-
* env/家目录——测试可用 BU_CONFIG_DIR 重定向)。
|
|
299
|
+
* env/家目录——测试可用 BU_CONFIG_DIR 重定向)。home/platform/fetchImpl/
|
|
300
|
+
* sleepImpl/env 属 bu:update 通道(定位与下载注入)。
|
|
181
301
|
* @returns {{type:'bu:pair:ok', cli:string, version:string, saved:number,
|
|
182
302
|
* received:number, truncated:boolean}
|
|
183
|
-
* |{type:'bu:pair:error', message:string}
|
|
303
|
+
* |{type:'bu:pair:error', message:string}
|
|
304
|
+
* |Promise<{type:'bu:update:ok'|'bu:update:error', …}>}
|
|
184
305
|
* P1-2:received=收到的原始条数、truncated=扩展侧是否声明截断——截断要如实
|
|
185
306
|
* 回执给 UI 显示「K 条未同步」,不再只报 saved 装作全量。
|
|
186
307
|
*/
|
|
187
308
|
export function handleNmMessage(msg, deps = {}) {
|
|
309
|
+
// bu:update 分流(唯一异步通道):形状校验/定位/下载全在 handleUpdateMessage 内
|
|
310
|
+
if (msg !== null && typeof msg === 'object' && !Array.isArray(msg) && msg.type === 'bu:update') {
|
|
311
|
+
return handleUpdateMessage(msg, deps);
|
|
312
|
+
}
|
|
188
313
|
const version = deps.version ?? pkg.version;
|
|
189
314
|
const check = validatePairMessage(msg);
|
|
190
315
|
if (!check.ok) return errorReply(check.message);
|
|
@@ -235,6 +360,21 @@ export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stde
|
|
|
235
360
|
stderr.write(`nm-host: 应答帧写出失败:${err?.message ?? err}\n`);
|
|
236
361
|
}
|
|
237
362
|
};
|
|
363
|
+
// A2(bu-online-selfupdate):应答帧统一经写队列串行化——bu:update 的应答是
|
|
364
|
+
// 异步产物(下载/替换),与后续消息的同步应答必须保序;同步应答只多等一个
|
|
365
|
+
// microtask,对端无感。处理异常(含 Promise 拒绝)兜底回 bu:pair:error 帧。
|
|
366
|
+
let writeChain = Promise.resolve();
|
|
367
|
+
const enqueueReply = (produce) => {
|
|
368
|
+
writeChain = writeChain.then(
|
|
369
|
+
() => Promise.resolve().then(produce).then(writeFrame, (err) => {
|
|
370
|
+
stderr.write(`nm-host: 消息处理异常:${err?.message ?? err}\n`);
|
|
371
|
+
writeFrame(errorReply(`处理异常:${err?.message ?? err}`));
|
|
372
|
+
}),
|
|
373
|
+
(err) => {
|
|
374
|
+
stderr.write(`nm-host: 应答写出失败:${err?.message ?? err}\n`);
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
};
|
|
238
378
|
let buf = Buffer.alloc(0);
|
|
239
379
|
// P2-1:超限帧流式丢弃——不清空整个缓冲(那会把同批到达的后续合法帧一起杀
|
|
240
380
|
// 掉),改为按「4+声明长度」记账,随到达字节扣减;扣完即恢复常规解帧。
|
|
@@ -258,7 +398,7 @@ export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stde
|
|
|
258
398
|
const r = decodeFrame(buf);
|
|
259
399
|
if (r.error === 'too-large') {
|
|
260
400
|
stderr.write(`nm-host: 帧超限(声明 ${r.declared}B > ${MAX_FRAME_BYTES}B)——流式丢弃 ${4 + r.declared}B\n`);
|
|
261
|
-
|
|
401
|
+
enqueueReply(() => errorReply(`帧超限:声明 ${r.declared}B > 上限 ${MAX_FRAME_BYTES}B`));
|
|
262
402
|
const total = 4 + r.declared;
|
|
263
403
|
const take = Math.min(buf.length, total);
|
|
264
404
|
discard = total - take;
|
|
@@ -273,15 +413,10 @@ export function runNmHost({ stdin = process.stdin, stdout = process.stdout, stde
|
|
|
273
413
|
msg = JSON.parse(r.payload);
|
|
274
414
|
} catch {
|
|
275
415
|
stderr.write('nm-host: 帧载荷不是合法 JSON\n');
|
|
276
|
-
|
|
416
|
+
enqueueReply(() => errorReply('帧载荷不是合法 JSON'));
|
|
277
417
|
continue;
|
|
278
418
|
}
|
|
279
|
-
|
|
280
|
-
writeFrame(handleNmMessage(msg, deps));
|
|
281
|
-
} catch (err) {
|
|
282
|
-
stderr.write(`nm-host: 消息处理异常:${err?.message ?? err}\n`);
|
|
283
|
-
writeFrame(errorReply(`处理异常:${err?.message ?? err}`));
|
|
284
|
-
}
|
|
419
|
+
enqueueReply(() => handleNmMessage(msg, deps));
|
|
285
420
|
}
|
|
286
421
|
});
|
|
287
422
|
stdin.on('end', () => finish(0)); // 客户端断开(Chrome 端 port.disconnect)→ 干净退出 0
|
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// 扩展在线自更新(change bu-online-selfupdate A1/A3)——安装目录发现+下载应用
|
|
2
|
+
// +`fde2e-bu update` 子命令。host 侧接线见 nm/host.mjs 的 bu:update。
|
|
3
|
+
//
|
|
4
|
+
// 设计要点(design C1~C5):
|
|
5
|
+
// - locateInstalls:扫 Chrome 系浏览器根(多平台、platform 可注入)各 profile 的
|
|
6
|
+
// Preferences/Secure Preferences → extensions.settings[<extId>].path(unpacked),
|
|
7
|
+
// 复核 manifest version(与 SW 上报 runningVersion 相符——防误伤旧拷贝);
|
|
8
|
+
// 受管目录(~/fde2e/bu/ 内或目录旁 .browser-extension-state.json)让路 omni App。
|
|
9
|
+
// - applyUpdate:https + 域名硬白名单(NM 路径;CLI 路径 httpsOnly 仅强制 https——
|
|
10
|
+
// C5 运维明知故为);流式落盘+字节数/sha256 双校验(不符不动旧目录);
|
|
11
|
+
// staging 解压(zip-slip/条目数/根 manifest/总量四重防护——对齐 omni pack.ts
|
|
12
|
+
// extractZipSafely 语义,参数按本包收敛为 256 条/64MB);rm+rename 原子替换
|
|
13
|
+
// (Windows 瞬态占用指数退避 6 次 250ms 起截断 8s——对齐 omni buPlugin.ts);
|
|
14
|
+
// finally 清 temp/staging;绝不写 sidecar(受管语义归 App 写入者)。
|
|
15
|
+
// - 零运行时依赖:zip 解析手写(store + deflate via zlib,zip64 拒绝)。
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import {
|
|
18
|
+
createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync,
|
|
19
|
+
renameSync, rmSync, writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { Readable } from 'node:stream';
|
|
23
|
+
import { pipeline } from 'node:stream/promises';
|
|
24
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
25
|
+
import { inflateRawSync } from 'node:zlib';
|
|
26
|
+
|
|
27
|
+
/** 扩展稳定 ID(manifest 固定 key 派生)——与 nm/bind.mjs 的 EXTENSION_ID 同值;
|
|
28
|
+
* 不跨模块引用是刻意的:bind→host→update→bind 会成环(bind 顶层常量在环上 TDZ 炸),
|
|
29
|
+
* 测试以「两处常量相等」断言防漂移。 */
|
|
30
|
+
export const EXTENSION_ID = 'meojillndgamkbfjijfehjaefolnfopn';
|
|
31
|
+
|
|
32
|
+
/** 官方 feed(C5 缺省;--feed 可覆写——测试/私有部署)。 */
|
|
33
|
+
export const DEFAULT_FEED_URL = 'https://www.fde2e.com/downloads/fde2e-bu-feed.json';
|
|
34
|
+
/** feed 内相对 url 的解析基址(design B)。 */
|
|
35
|
+
export const DOWNLOADS_BASE = 'https://www.fde2e.com/downloads/';
|
|
36
|
+
/** NM 路径下载域名硬白名单(C3:防 feed 被用作任意下载器)。 */
|
|
37
|
+
export const UPDATE_HOST_ALLOWLIST_HOST = 'www.fde2e.com';
|
|
38
|
+
/** omni App 受管标记:目录旁 sidecar 文件名(proposal 硬约束 1)。 */
|
|
39
|
+
export const SIDECAR_NAME = '.browser-extension-state.json';
|
|
40
|
+
/** 解压防护上限(对齐 omni extractZipSafely 语义、按本包收敛的参数)。 */
|
|
41
|
+
export const EXTRACT_MAX_ENTRIES = 256;
|
|
42
|
+
export const EXTRACT_MAX_BYTES = 64 * 1024 * 1024;
|
|
43
|
+
/** Windows 瞬态占用退避(对齐 omni buPlugin.ts 实证参数)。 */
|
|
44
|
+
export const RETRY_MAX = 6;
|
|
45
|
+
export const RETRY_BASE_MS = 250;
|
|
46
|
+
export const RETRY_CAP_MS = 8_000;
|
|
47
|
+
/** 目录替换可重试的瞬态错误码(Windows 文件占用家族;EEXIST 同族兜底)。 */
|
|
48
|
+
export const TRANSIENT_FS_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY', 'EEXIST']);
|
|
49
|
+
|
|
50
|
+
/** 更新流程错误:code ∈ bad-url|verify|io(host 侧再映射 managed/not-found/bad-msg)。 */
|
|
51
|
+
export function updateError(code, message) {
|
|
52
|
+
const err = new Error(message ?? code);
|
|
53
|
+
err.name = 'UpdateError';
|
|
54
|
+
err.code = code;
|
|
55
|
+
return err;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- semver(C5:feed 比较只认 >;等/低 no-op) ----------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解析版本串(Chrome manifest 口径:1~4 段数字,可带 -prerelease/+build)。
|
|
62
|
+
* @returns {number[]|null} 数值段数组(短缺补 0 到 3 段);不可解析 → null
|
|
63
|
+
*/
|
|
64
|
+
export function parseSemver(v) {
|
|
65
|
+
if (typeof v !== 'string') return null;
|
|
66
|
+
const core = v.trim().replace(/^v=/, '').replace(/^v/, '').split('+')[0];
|
|
67
|
+
const [numsRaw, pre] = core.split('-');
|
|
68
|
+
const parts = numsRaw.split('.').map((s) => (/^\d+$/.test(s) ? Number(s) : NaN));
|
|
69
|
+
if (parts.length < 1 || parts.length > 4 || parts.some((n) => !Number.isSafeInteger(n) || n < 0)) return null;
|
|
70
|
+
while (parts.length < 3) parts.push(0);
|
|
71
|
+
return { nums: parts, pre: pre === undefined ? null : pre };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** semver 比较:a>b → 1、相等 → 0、a<b → -1(prerelease 按 semver 点 11:无 > 有)。 */
|
|
75
|
+
export function compareSemver(a, b) {
|
|
76
|
+
const pa = parseSemver(a);
|
|
77
|
+
const pb = parseSemver(b);
|
|
78
|
+
if (!pa || !pb) return NaN; // 调用方保证可解析(feed/manifest 均先校验)
|
|
79
|
+
for (let i = 0; i < 3; i++) {
|
|
80
|
+
if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i] ? 1 : -1;
|
|
81
|
+
}
|
|
82
|
+
if (pa.pre === pb.pre) return 0;
|
|
83
|
+
if (pa.pre === null) return 1; // 无 prerelease > 有
|
|
84
|
+
if (pb.pre === null) return -1;
|
|
85
|
+
const sa = pa.pre.split('.');
|
|
86
|
+
const sb = pb.pre.split('.');
|
|
87
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
88
|
+
const x = sa[i];
|
|
89
|
+
const y = sb[i];
|
|
90
|
+
if (x === undefined) return -1; // 短前缀 < 长
|
|
91
|
+
if (y === undefined) return 1;
|
|
92
|
+
const xn = /^\d+$/.test(x);
|
|
93
|
+
const yn = /^\d+$/.test(y);
|
|
94
|
+
if (xn && yn) {
|
|
95
|
+
if (Number(x) !== Number(y)) return Number(x) > Number(y) ? 1 : -1;
|
|
96
|
+
} else if (xn !== yn) {
|
|
97
|
+
return xn ? -1 : 1; // 数值标识 < 字母数字标识
|
|
98
|
+
} else if (x !== y) {
|
|
99
|
+
return x > y ? 1 : -1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------- C1 目录发现 ----------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Chrome 系浏览器用户数据根(存在才扫;platform 可注入供测试)。
|
|
109
|
+
* @returns {{ label:string, root:string }[]} label ∈ chrome|chrome-beta|chrome-dev|
|
|
110
|
+
* chrome-canary|chromium(macOS 的 Chromium 双路径同 label、按路径去重)
|
|
111
|
+
*/
|
|
112
|
+
export function browserScanRoots(home = homedir(), platform = process.platform) {
|
|
113
|
+
let roots;
|
|
114
|
+
if (platform === 'win32') {
|
|
115
|
+
const lad = join(home, 'AppData', 'Local'); // %LOCALAPPDATA% 缺省位置
|
|
116
|
+
roots = [
|
|
117
|
+
['chrome', join(lad, 'Google', 'Chrome', 'User Data')],
|
|
118
|
+
['chrome-beta', join(lad, 'Google', 'Chrome Beta', 'User Data')],
|
|
119
|
+
['chrome-dev', join(lad, 'Google', 'Chrome Dev', 'User Data')],
|
|
120
|
+
['chrome-canary', join(lad, 'Google', 'Chrome Canary', 'User Data')],
|
|
121
|
+
['chromium', join(lad, 'Chromium', 'User Data')],
|
|
122
|
+
];
|
|
123
|
+
} else if (platform === 'linux') {
|
|
124
|
+
const cfg = join(home, '.config');
|
|
125
|
+
roots = [
|
|
126
|
+
['chrome', join(cfg, 'google-chrome')],
|
|
127
|
+
['chromium', join(cfg, 'chromium')],
|
|
128
|
+
['chromium', join(cfg, 'chromium-browser')],
|
|
129
|
+
];
|
|
130
|
+
} else {
|
|
131
|
+
// macOS(及其它平台按 macOS 路径兜底——与 bind.browserManifestDirs 同策略)
|
|
132
|
+
const as = join(home, 'Library', 'Application Support');
|
|
133
|
+
roots = [
|
|
134
|
+
['chrome', join(as, 'Google', 'Chrome')],
|
|
135
|
+
['chrome-beta', join(as, 'Google', 'Chrome Beta')],
|
|
136
|
+
['chrome-dev', join(as, 'Google', 'Chrome Dev')],
|
|
137
|
+
['chrome-canary', join(as, 'Google', 'Chrome Canary')],
|
|
138
|
+
['chromium', join(as, 'Google', 'Chromium')], // 任务规格字面路径
|
|
139
|
+
['chromium', join(as, 'Chromium')], // macOS Chromium 平台规范路径
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
return roots.filter(([label, root]) => (seen.has(root) ? false : (seen.add(root), true)))
|
|
144
|
+
.map(([label, root]) => ({ label, root }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** profile 目录名(Chrome 约定:Default / Profile N)。 */
|
|
148
|
+
export function isProfileDir(name) {
|
|
149
|
+
return name === 'Default' || /^Profile \d+$/.test(name);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 读 profile 的 Preferences/Secure Preferences → extensions.settings[extId].path。
|
|
153
|
+
* 存在 path 字段即视为 unpacked(design C1.2);坏 JSON/无条目 → null。 */
|
|
154
|
+
export function readUnpackedPath(profileDir, extId = EXTENSION_ID) {
|
|
155
|
+
for (const file of ['Preferences', 'Secure Preferences']) {
|
|
156
|
+
let data;
|
|
157
|
+
try {
|
|
158
|
+
data = JSON.parse(readFileSync(join(profileDir, file), 'utf8'));
|
|
159
|
+
} catch {
|
|
160
|
+
continue; // 不存在/坏 JSON → 换下一个来源
|
|
161
|
+
}
|
|
162
|
+
const entry = data?.extensions?.settings?.[extId];
|
|
163
|
+
if (entry && typeof entry === 'object' && typeof entry.path === 'string' && entry.path !== '') {
|
|
164
|
+
return { path: entry.path, source: file };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 目录内 manifest.json 的 version(缺失/坏 JSON/无 version → null)。 */
|
|
171
|
+
export function readDirVersion(dir) {
|
|
172
|
+
try {
|
|
173
|
+
const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
|
|
174
|
+
return typeof m?.version === 'string' && m.version !== '' ? m.version : null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 受管判定(C2):规范化路径位于 <home>/fde2e/bu/ 内,或目录旁存在 sidecar。 */
|
|
181
|
+
export function isManagedPath(absDir, home = homedir()) {
|
|
182
|
+
const managedRoot = resolve(home, 'fde2e', 'bu');
|
|
183
|
+
if (absDir === managedRoot || absDir.startsWith(managedRoot + sep)) return true;
|
|
184
|
+
return existsSync(join(dirname(absDir), SIDECAR_NAME));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 扫描本机 unpacked 安装(C1)。
|
|
189
|
+
* @param {{ home?: string, extId?: string, runningVersion?: string,
|
|
190
|
+
* platform?: string }} opts
|
|
191
|
+
* runningVersion 给定(NM 路径)→ manifest version 必须相等,否则进 rejected
|
|
192
|
+
* (旧拷贝,如实列出);undefined(CLI 路径)→ 不做版本过滤(各安装自带版本
|
|
193
|
+
* 参与后续 semver 比较)。
|
|
194
|
+
* @returns {{ matches: Array<{path, browser, profile, version}>,
|
|
195
|
+
* rejected: Array<{path, browser, profile, reason, found?, expected?}>,
|
|
196
|
+
* managed: Array<{path, browser, profile}> }}
|
|
197
|
+
* 跨浏览器/多 profile 引用同一目录时按规范化路径去重(首见归属)。
|
|
198
|
+
*/
|
|
199
|
+
export function locateInstalls({ home = homedir(), extId = EXTENSION_ID, runningVersion, platform = process.platform } = {}) {
|
|
200
|
+
const matches = [];
|
|
201
|
+
const rejected = [];
|
|
202
|
+
const managed = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
for (const { label, root } of browserScanRoots(home, platform)) {
|
|
205
|
+
let profiles;
|
|
206
|
+
try {
|
|
207
|
+
profiles = readdirSync(root).filter(isProfileDir);
|
|
208
|
+
} catch {
|
|
209
|
+
continue; // 根不存在/不可读 → 该浏览器无安装
|
|
210
|
+
}
|
|
211
|
+
for (const profile of profiles) {
|
|
212
|
+
const found = readUnpackedPath(join(root, profile), extId);
|
|
213
|
+
if (!found) continue;
|
|
214
|
+
const path = resolve(found.path);
|
|
215
|
+
if (seen.has(path)) continue;
|
|
216
|
+
seen.add(path);
|
|
217
|
+
const at = { path, browser: label, profile };
|
|
218
|
+
if (isManagedPath(path, home)) {
|
|
219
|
+
managed.push(at);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const version = readDirVersion(path);
|
|
223
|
+
if (version === null) {
|
|
224
|
+
rejected.push({ ...at, reason: 'no-manifest' });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (runningVersion !== undefined && version !== runningVersion) {
|
|
228
|
+
rejected.push({ ...at, reason: 'version-mismatch', found: version, expected: runningVersion });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
matches.push({ ...at, version });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { matches, rejected, managed };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------- zip 解析(零依赖:store + deflate;zip64 拒绝) ----------
|
|
238
|
+
|
|
239
|
+
const EOCD_SIG = 0x06054b50;
|
|
240
|
+
const CDE_SIG = 0x02014b50;
|
|
241
|
+
const LFH_SIG = 0x04034b50;
|
|
242
|
+
|
|
243
|
+
/** 解析 zip 中央目录(纯函数)。@returns {{ entries, base }}——base=前缀偏移修正。 */
|
|
244
|
+
export function listZipEntries(buf) {
|
|
245
|
+
const minEocd = 22;
|
|
246
|
+
const maxScan = Math.min(buf.length, minEocd + 0xffff); // EOCD + 最大注释区
|
|
247
|
+
let eocd = -1;
|
|
248
|
+
for (let i = buf.length - minEocd; i >= buf.length - maxScan && i >= 0; i--) {
|
|
249
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
|
|
250
|
+
}
|
|
251
|
+
if (eocd < 0) throw updateError('verify', '更新包不是合法 zip(缺少 EOCD 结尾记录)');
|
|
252
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
253
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
254
|
+
const cdOff = buf.readUInt32LE(eocd + 16);
|
|
255
|
+
if (count === 0xffff || cdOff === 0xffffffff) {
|
|
256
|
+
throw updateError('verify', '更新包为 zip64 形态(不支持——本包体积上限 64MB 无需 zip64)');
|
|
257
|
+
}
|
|
258
|
+
// 前缀偏移修正(自解压头等场景:EOCD 声明的偏移与实际位置差值即前缀长度)
|
|
259
|
+
let base = 0;
|
|
260
|
+
if (cdOff + cdSize !== eocd) {
|
|
261
|
+
base = eocd - cdSize - cdOff;
|
|
262
|
+
if (base < 0) throw updateError('verify', '更新包中央目录定位失败');
|
|
263
|
+
}
|
|
264
|
+
const entries = [];
|
|
265
|
+
let p = cdOff + base;
|
|
266
|
+
for (let i = 0; i < count; i++) {
|
|
267
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CDE_SIG) {
|
|
268
|
+
throw updateError('verify', '更新包中央目录损坏');
|
|
269
|
+
}
|
|
270
|
+
const method = buf.readUInt16LE(p + 10);
|
|
271
|
+
const csize = buf.readUInt32LE(p + 20);
|
|
272
|
+
const usize = buf.readUInt32LE(p + 24);
|
|
273
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
274
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
275
|
+
const cmtLen = buf.readUInt16LE(p + 32);
|
|
276
|
+
const lho = buf.readUInt32LE(p + 42);
|
|
277
|
+
if (csize === 0xffffffff || usize === 0xffffffff || lho === 0xffffffff) {
|
|
278
|
+
throw updateError('verify', '更新包为 zip64 形态(不支持)');
|
|
279
|
+
}
|
|
280
|
+
entries.push({
|
|
281
|
+
name: buf.subarray(p + 46, p + 46 + nameLen).toString('utf8'),
|
|
282
|
+
method,
|
|
283
|
+
csize,
|
|
284
|
+
usize,
|
|
285
|
+
lho,
|
|
286
|
+
});
|
|
287
|
+
p += 46 + nameLen + extraLen + cmtLen;
|
|
288
|
+
}
|
|
289
|
+
return { entries, base };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 解出单条目数据(store 直取;deflate 走 zlib,maxOutputLength 防单条炸弹)。 */
|
|
293
|
+
function readZipEntryData(buf, entry, base) {
|
|
294
|
+
const lho = entry.lho + base;
|
|
295
|
+
if (lho + 30 > buf.length || buf.readUInt32LE(lho) !== LFH_SIG) {
|
|
296
|
+
throw updateError('verify', `更新包条目头损坏:${entry.name}`);
|
|
297
|
+
}
|
|
298
|
+
const nameLen = buf.readUInt16LE(lho + 26);
|
|
299
|
+
const extraLen = buf.readUInt16LE(lho + 28);
|
|
300
|
+
const start = lho + 30 + nameLen + extraLen;
|
|
301
|
+
const end = start + entry.csize;
|
|
302
|
+
if (end > buf.length) throw updateError('verify', `更新包条目数据截断:${entry.name}`);
|
|
303
|
+
const raw = buf.subarray(start, end);
|
|
304
|
+
if (entry.method === 0) return Buffer.from(raw);
|
|
305
|
+
if (entry.method === 8) {
|
|
306
|
+
let out;
|
|
307
|
+
try {
|
|
308
|
+
out = inflateRawSync(raw, { maxOutputLength: EXTRACT_MAX_BYTES + 1 });
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (err?.code === 'ERR_BUFFER_TOO_LARGE') throw updateError('verify', `更新包条目解压超限:${entry.name}`);
|
|
311
|
+
throw updateError('verify', `更新包条目解压失败(${err?.message ?? err}):${entry.name}`);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
throw updateError('verify', `更新包条目压缩方式不支持(method=${entry.method}):${entry.name}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 安全解压(对齐 omni pack.ts extractZipSafely 语义,参数按本包收敛):
|
|
320
|
+
* zip-slip 拒绝(规范化后必须落在 destDir 内;绝对路径/../盘符/UNC 一律拒)+
|
|
321
|
+
* 条目数 ≤256 + 根必须有 manifest.json + 总解压体积 ≤64MB。
|
|
322
|
+
* @returns {object} 根 manifest.json 解析结果(含 version 等)
|
|
323
|
+
*/
|
|
324
|
+
export function extractZipSafely(zipPath, destDir, { rootFile = 'manifest.json', what = '更新包' } = {}) {
|
|
325
|
+
let buf;
|
|
326
|
+
try {
|
|
327
|
+
buf = readFileSync(zipPath);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
throw updateError('io', `读取更新包失败:${err?.message ?? err}`);
|
|
330
|
+
}
|
|
331
|
+
const { entries, base } = listZipEntries(buf);
|
|
332
|
+
if (entries.length > EXTRACT_MAX_ENTRIES) {
|
|
333
|
+
throw updateError('verify', `${what}条目数超限(${entries.length} > ${EXTRACT_MAX_ENTRIES})`);
|
|
334
|
+
}
|
|
335
|
+
const destAbs = resolve(destDir);
|
|
336
|
+
mkdirSync(destAbs, { recursive: true });
|
|
337
|
+
let total = 0;
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
const name = entry.name.replace(/\\/g, '/');
|
|
340
|
+
if (name.startsWith('/') || /^[A-Za-z]:/.test(name) || name.startsWith('//')) {
|
|
341
|
+
throw updateError('verify', `${what}含非法路径条目(绝对路径):${entry.name}`);
|
|
342
|
+
}
|
|
343
|
+
const segs = name.split('/').filter((s) => s !== '' && s !== '.');
|
|
344
|
+
if (segs.length === 0 || segs.includes('..')) {
|
|
345
|
+
throw updateError('verify', `${what}含非法路径条目(越界):${entry.name}`);
|
|
346
|
+
}
|
|
347
|
+
const target = join(destAbs, ...segs);
|
|
348
|
+
if (target !== destAbs && !target.startsWith(destAbs + sep)) {
|
|
349
|
+
throw updateError('verify', `${what}含非法路径条目(逃逸 staging):${entry.name}`);
|
|
350
|
+
}
|
|
351
|
+
if (name.endsWith('/')) {
|
|
352
|
+
mkdirSync(target, { recursive: true }); // 目录条目
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
total += entry.usize;
|
|
356
|
+
if (total > EXTRACT_MAX_BYTES) {
|
|
357
|
+
throw updateError('verify', `${what}解压总量超限(> ${EXTRACT_MAX_BYTES} 字节)`);
|
|
358
|
+
}
|
|
359
|
+
const data = readZipEntryData(buf, entry, base);
|
|
360
|
+
if (data.length !== entry.usize) {
|
|
361
|
+
throw updateError('verify', `${what}条目尺寸与目录声明不符:${entry.name}`);
|
|
362
|
+
}
|
|
363
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
364
|
+
writeFileSync(target, data);
|
|
365
|
+
}
|
|
366
|
+
let manifest;
|
|
367
|
+
try {
|
|
368
|
+
manifest = JSON.parse(readFileSync(join(destAbs, rootFile), 'utf8'));
|
|
369
|
+
} catch {
|
|
370
|
+
throw updateError('verify', `${what}缺 ${rootFile} 或不可解析(包结构不符)`);
|
|
371
|
+
}
|
|
372
|
+
return manifest;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------- 下载策略(BU_UPDATE_BASE:测试/私有部署覆写) ----------
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 下载基址与白名单(NM 路径用)。BU_UPDATE_BASE 覆写=信任重定向到指定基址
|
|
379
|
+
* (E2E 假 feed server / 私有部署用),**不是**放宽为任意域:允许域=基址 host,
|
|
380
|
+
* http 仅当基址显式为 http(本机回环假服务器)。缺省=www.fde2e.com 硬白名单。
|
|
381
|
+
*/
|
|
382
|
+
export function downloadPolicy(env = process.env) {
|
|
383
|
+
const override = env.BU_UPDATE_BASE;
|
|
384
|
+
if (override !== undefined && override !== '') {
|
|
385
|
+
let u;
|
|
386
|
+
try {
|
|
387
|
+
u = new URL(override);
|
|
388
|
+
} catch {
|
|
389
|
+
throw updateError('bad-url', `BU_UPDATE_BASE 无法解析:${override}`);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
base: override.endsWith('/') ? override : `${override}/`,
|
|
393
|
+
allowHost: u.hostname,
|
|
394
|
+
allowHttp: u.protocol === 'http:',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return { base: DOWNLOADS_BASE, allowHost: UPDATE_HOST_ALLOWLIST_HOST, allowHttp: false };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** NM 路径 url 校验:相对形态按基址解析;协议/域名过白名单(C3 硬白名单)。 */
|
|
401
|
+
export function resolveNmUrl(raw, env = process.env) {
|
|
402
|
+
const policy = downloadPolicy(env);
|
|
403
|
+
let u;
|
|
404
|
+
try {
|
|
405
|
+
u = new URL(String(raw), policy.base);
|
|
406
|
+
} catch {
|
|
407
|
+
throw updateError('bad-url', `下载 url 无法解析:${raw}`);
|
|
408
|
+
}
|
|
409
|
+
const wantProto = policy.allowHttp ? /^https?:$/ : /^https:$/;
|
|
410
|
+
if (!wantProto.test(u.protocol)) {
|
|
411
|
+
throw updateError('bad-url', `下载 url 协议必须 ${policy.allowHttp ? 'http(s)' : 'https'}(收到 ${u.protocol}//${u.hostname})`);
|
|
412
|
+
}
|
|
413
|
+
if (u.hostname !== policy.allowHost) {
|
|
414
|
+
throw updateError('bad-url', `下载 url 域名不在白名单(只允许 ${policy.allowHost},收到 ${u.hostname})`);
|
|
415
|
+
}
|
|
416
|
+
return u;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------- C3 下载与应用 ----------
|
|
420
|
+
|
|
421
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
422
|
+
|
|
423
|
+
/** 陈旧 temp/staging 清扫:属主 pid 已死才清(活进程的产物不动)。 */
|
|
424
|
+
export function sweepStaleArtifacts(parentDir) {
|
|
425
|
+
let names;
|
|
426
|
+
try {
|
|
427
|
+
names = readdirSync(parentDir);
|
|
428
|
+
} catch {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
for (const n of names) {
|
|
432
|
+
const m = /^\.bu-update-(\d+)\.zip$/.exec(n) ?? /^\.browser-extension\.update-(\d+)$/.exec(n)
|
|
433
|
+
?? /\.old-(\d+)$/.exec(n);
|
|
434
|
+
if (!m) continue;
|
|
435
|
+
const pid = Number(m[1]);
|
|
436
|
+
if (pid === process.pid) continue;
|
|
437
|
+
try {
|
|
438
|
+
process.kill(pid, 0); // ESRCH=属主已死才可清;EPERM=活着但无权探测 → 不清
|
|
439
|
+
continue;
|
|
440
|
+
} catch (err) {
|
|
441
|
+
if (err?.code === 'EPERM') continue;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
rmSync(join(parentDir, n), { recursive: true, force: true });
|
|
445
|
+
} catch { /* 清扫失败不阻断更新 */ }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 目录原子替换:三步交换(rename 旧→.old → rename staging→dest → 清 .old),任一步
|
|
451
|
+
* 瞬态占用(EPERM/EBUSY/EACCES/ENOTEMPTY,Windows 杀软/索引器/浏览器正读)指数退避
|
|
452
|
+
* 重试(6 次 250ms 起截断 8s)。第二步失败时回滚(.old → dest),旧目录始终在场——
|
|
453
|
+
* 杜绝「rm 成功 + rename 耗尽」把安装目录清空的毁灭尾迹(对抗审查 P1-1);进程在
|
|
454
|
+
* 步骤间被杀留下的 .old-<pid>/staging 残留由 sweepStaleArtifacts 按属主探活清理。
|
|
455
|
+
* rm/rename/sleep 可注入(单测占用重试矩阵用 fast-sleep 不实等 7.9s)。
|
|
456
|
+
*/
|
|
457
|
+
export async function replaceDirAtomic(staging, dest, {
|
|
458
|
+
sleepImpl = defaultSleep, rmImpl = rmSync, renameImpl = renameSync,
|
|
459
|
+
} = {}) {
|
|
460
|
+
const old = `${dest}.old-${process.pid}`;
|
|
461
|
+
rmImpl(old, { recursive: true, force: true });
|
|
462
|
+
const run = async (label, fn) => {
|
|
463
|
+
let lastErr;
|
|
464
|
+
for (let attempt = 0; attempt < RETRY_MAX; attempt++) {
|
|
465
|
+
try {
|
|
466
|
+
return fn();
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (!TRANSIENT_FS_CODES.has(err?.code)) {
|
|
469
|
+
const wrapped = updateError('io', `目录替换失败(${label}):${err?.message ?? err}`);
|
|
470
|
+
wrapped.causeCode = err?.code; // 调用方按原始错误码分流(如 ENOENT=dest 不在场)
|
|
471
|
+
throw wrapped;
|
|
472
|
+
}
|
|
473
|
+
lastErr = err;
|
|
474
|
+
if (attempt < RETRY_MAX - 1) {
|
|
475
|
+
await sleepImpl(Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_CAP_MS));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
throw updateError('io', `目录替换失败(${label},瞬态占用重试 ${RETRY_MAX} 次后放弃):${lastErr?.message ?? lastErr}`);
|
|
480
|
+
};
|
|
481
|
+
// 让位:dest 存在 → rename 到 .old(瞬态退避);dest 不存在(ENOENT=全新安装)→ 跳过
|
|
482
|
+
let hasOld = false;
|
|
483
|
+
try {
|
|
484
|
+
await run('旧目录让位', () => renameImpl(dest, old));
|
|
485
|
+
hasOld = true;
|
|
486
|
+
} catch (err) {
|
|
487
|
+
rmImpl(old, { recursive: true, force: true });
|
|
488
|
+
if (err?.causeCode !== 'ENOENT') throw err;
|
|
489
|
+
// ENOENT:dest 不在场=全新安装,无旧可让/可回滚
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
await run('新目录就位', () => renameImpl(staging, dest));
|
|
493
|
+
} catch (err) {
|
|
494
|
+
if (hasOld) {
|
|
495
|
+
try { await run('回滚', () => renameImpl(old, dest)); } catch { /* 回滚也败:.old 在场,下次 sweep/重试自愈 */ }
|
|
496
|
+
}
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
if (hasOld) rmImpl(old, { recursive: true, force: true });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 下载并应用更新到 destDir(C3 全流程)。
|
|
504
|
+
* @param {{ url: string, sha256: string, size: number, destDir: string,
|
|
505
|
+
* fetchImpl?: Function, sleepImpl?: Function, httpsOnly?: boolean,
|
|
506
|
+
* env?: object }} opts
|
|
507
|
+
* httpsOnly(CLI 路径,C5):只强制 https、不限域名(运维明知故为);
|
|
508
|
+
* 缺省(NM 路径):https + www.fde2e.com 硬白名单(BU_UPDATE_BASE 可重定向)。
|
|
509
|
+
* @returns {Promise<{from: string|null, to: string, path: string}>}
|
|
510
|
+
* from=旧目录 manifest version(目录缺失 → null)、to=新包 manifest version。
|
|
511
|
+
* @throws {Error&{code:'bad-url'|'verify'|'io'}} 失败时旧目录保持原样(verify/bad-url
|
|
512
|
+
* 绝不触碰;io 仅替换阶段可能已 rm——rename 失败重试耗尽属不可恢复 IO 错)。
|
|
513
|
+
*/
|
|
514
|
+
export async function applyUpdate({ url, sha256, size, destDir, fetchImpl, sleepImpl, httpsOnly = false, env = process.env }) {
|
|
515
|
+
// 1. url 校验(CLI:绝对 https 任意域;NM:基址解析 + 硬白名单)
|
|
516
|
+
let u;
|
|
517
|
+
if (httpsOnly) {
|
|
518
|
+
try {
|
|
519
|
+
u = new URL(String(url));
|
|
520
|
+
} catch {
|
|
521
|
+
throw updateError('bad-url', `下载 url 无法解析:${url}`);
|
|
522
|
+
}
|
|
523
|
+
if (u.protocol !== 'https:') {
|
|
524
|
+
throw updateError('bad-url', `下载 url 协议必须 https(收到 ${u.protocol}//${u.hostname})`);
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
u = resolveNmUrl(url, env);
|
|
528
|
+
}
|
|
529
|
+
if (typeof sha256 !== 'string' || !/^[0-9a-fA-F]{64}$/.test(sha256)) {
|
|
530
|
+
throw updateError('verify', 'sha256 须为 64 位 hex');
|
|
531
|
+
}
|
|
532
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
533
|
+
throw updateError('verify', 'size 须为正整数');
|
|
534
|
+
}
|
|
535
|
+
const dest = resolve(String(destDir));
|
|
536
|
+
const parent = dirname(dest);
|
|
537
|
+
mkdirSync(parent, { recursive: true });
|
|
538
|
+
sweepStaleArtifacts(parent); // 前次中断残留(属主已死)顺手清
|
|
539
|
+
const tempZip = join(parent, `.bu-update-${process.pid}.zip`);
|
|
540
|
+
const staging = join(parent, `.browser-extension.update-${process.pid}`);
|
|
541
|
+
rmSync(tempZip, { force: true });
|
|
542
|
+
rmSync(staging, { recursive: true, force: true });
|
|
543
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
544
|
+
if (typeof fetchFn !== 'function') throw updateError('io', '无可用 fetch(Node >= 18)');
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
// 2. 流式下载(超出 size 即断——feed 声明即预算,不做无界落盘);
|
|
548
|
+
// redirect:'error' 拒绝跟随 30x——白名单只校验首跳 URL(对抗审查 P2.1 收紧)
|
|
549
|
+
let res;
|
|
550
|
+
try {
|
|
551
|
+
res = await fetchFn(u.href, { redirect: 'error' });
|
|
552
|
+
} catch (err) {
|
|
553
|
+
throw updateError('io', `下载失败(${u.host}):${err?.message ?? err}`);
|
|
554
|
+
}
|
|
555
|
+
if (!res || typeof res.ok !== 'boolean' || !res.ok) {
|
|
556
|
+
throw updateError('io', `下载失败(${u.host}):HTTP ${res?.status ?? '???'}`);
|
|
557
|
+
}
|
|
558
|
+
if (!res.body) throw updateError('io', '下载响应无 body');
|
|
559
|
+
const source = res.body.getReader ? Readable.fromWeb(res.body) : Readable.from(res.body);
|
|
560
|
+
const hash = createHash('sha256');
|
|
561
|
+
const sink = createWriteStream(tempZip);
|
|
562
|
+
let bytes = 0;
|
|
563
|
+
try {
|
|
564
|
+
await pipeline(source, async function* verifyAndPass(chunks) {
|
|
565
|
+
for await (const chunk of chunks) {
|
|
566
|
+
bytes += chunk.length;
|
|
567
|
+
if (bytes > size) {
|
|
568
|
+
throw updateError('verify', `下载数超预期(>${size}B——feed 声明与实际不符)`);
|
|
569
|
+
}
|
|
570
|
+
hash.update(chunk);
|
|
571
|
+
yield chunk;
|
|
572
|
+
}
|
|
573
|
+
}, sink);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
if (err?.code === 'verify') throw err;
|
|
576
|
+
throw updateError('io', `下载中断:${err?.message ?? err}`);
|
|
577
|
+
} finally {
|
|
578
|
+
sink.destroy();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 3. 字节数 + sha256 双校验(不符删 temp 报 verify——绝不碰旧目录)
|
|
582
|
+
if (bytes !== size) {
|
|
583
|
+
throw updateError('verify', `字节数不符(feed=${size},实收=${bytes})`);
|
|
584
|
+
}
|
|
585
|
+
const hex = hash.digest('hex');
|
|
586
|
+
if (hex !== sha256.toLowerCase()) {
|
|
587
|
+
throw updateError('verify', `sha256 不符(feed=${sha256.toLowerCase()},实算=${hex})`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 4. staging 解压(zip-slip/条目/形状/总量四重防护)+读新版本
|
|
591
|
+
const manifest = extractZipSafely(tempZip, staging);
|
|
592
|
+
if (typeof manifest?.version !== 'string' || manifest.version === '') {
|
|
593
|
+
throw updateError('verify', '更新包 manifest.json 缺 version(包结构不符)');
|
|
594
|
+
}
|
|
595
|
+
const from = readDirVersion(dest);
|
|
596
|
+
|
|
597
|
+
// 5. 原子替换(瞬态占用退避);finally 清 temp/staging;不写任何 sidecar
|
|
598
|
+
await replaceDirAtomic(staging, dest, sleepImpl ? { sleepImpl } : {});
|
|
599
|
+
return { from, to: manifest.version, path: dest };
|
|
600
|
+
} finally {
|
|
601
|
+
rmSync(tempZip, { force: true });
|
|
602
|
+
rmSync(staging, { recursive: true, force: true });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------- feed 契约(B)+ CLI 直跑(C5) ----------
|
|
607
|
+
|
|
608
|
+
/** feed 形状校验:{version, url, sha256, size} 必填(date/notes 可选)。 */
|
|
609
|
+
export function validateFeed(feed) {
|
|
610
|
+
if (!feed || typeof feed !== 'object' || Array.isArray(feed)) return 'feed 须为 JSON 对象';
|
|
611
|
+
if (typeof feed.version !== 'string' || feed.version === '') return 'feed.version 须为非空字符串';
|
|
612
|
+
if (parseSemver(feed.version) === null) return `feed.version 不是合法版本串:${feed.version}`;
|
|
613
|
+
if (typeof feed.url !== 'string' || feed.url === '') return 'feed.url 须为非空字符串';
|
|
614
|
+
if (typeof feed.sha256 !== 'string' || !/^[0-9a-fA-F]{64}$/.test(feed.sha256)) return 'feed.sha256 须为 64 位 hex';
|
|
615
|
+
if (!Number.isSafeInteger(feed.size) || feed.size <= 0) return 'feed.size 须为正整数';
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* `fde2e-bu update` 子命令(C5):发现(--ext-dir 免扫描 / 缺省 C1 扫描)→
|
|
621
|
+
* feed 检查(semver 只认 >)→ 逐目录 applyUpdate(httpsOnly)。
|
|
622
|
+
* 0 命中 exit 1+指引;受管安装让路(含 --ext-dir 指向受管目录);--dry-run 只报不写。
|
|
623
|
+
* @returns {Promise<number>} exit code
|
|
624
|
+
*/
|
|
625
|
+
export async function cmdSelfUpdate({
|
|
626
|
+
extDir, feed = DEFAULT_FEED_URL, dryRun = false,
|
|
627
|
+
home = homedir(), platform = process.platform,
|
|
628
|
+
fetchImpl, sleepImpl, env = process.env,
|
|
629
|
+
} = {}) {
|
|
630
|
+
// 1. 发现(先于任何网络访问——0 命中不需要 feed)
|
|
631
|
+
let targets = [];
|
|
632
|
+
if (extDir !== undefined) {
|
|
633
|
+
const abs = resolve(String(extDir));
|
|
634
|
+
if (isManagedPath(abs, home)) {
|
|
635
|
+
console.error(`跳过:${abs} 由 FDE2E Omni 桌面版管理,插件随 App 更新。`);
|
|
636
|
+
return 1;
|
|
637
|
+
}
|
|
638
|
+
const version = readDirVersion(abs);
|
|
639
|
+
if (version === null) {
|
|
640
|
+
console.error(`--ext-dir 目录缺 manifest.json(或无 version 字段):${abs}`);
|
|
641
|
+
return 1;
|
|
642
|
+
}
|
|
643
|
+
targets = [{ path: abs, browser: 'ext-dir', profile: '-', version }];
|
|
644
|
+
} else {
|
|
645
|
+
const found = locateInstalls({ home, platform }); // CLI 无 runningVersion:不过滤版本
|
|
646
|
+
for (const m of found.managed) console.log(`跳过(受 FDE2E Omni 桌面版管理,插件随 App 更新):${m.path}`);
|
|
647
|
+
for (const r of found.rejected) console.log(`跳过(manifest 缺失或不可解析):${r.path}`);
|
|
648
|
+
targets = found.matches;
|
|
649
|
+
if (targets.length === 0) {
|
|
650
|
+
console.error('未找到 unpacked 安装(0 命中)。');
|
|
651
|
+
console.error(' 1. 确认浏览器已装 FDE2E 扩展(unpacked 加载)后重试;');
|
|
652
|
+
console.error(' 2. 安装在非常规位置时用 --ext-dir <目录> 显式指定;');
|
|
653
|
+
console.error(' 3. 受 FDE2E Omni 桌面版管理的安装随 App 更新,不走本命令。');
|
|
654
|
+
return 1;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
console.log(`发现 ${targets.length} 处安装:`);
|
|
658
|
+
for (const t of targets) console.log(` - ${t.path}(${t.browser}/${t.profile},v${t.version})`);
|
|
659
|
+
|
|
660
|
+
// 2. feed(--feed 仅强制 https——C5 运维明知故为)
|
|
661
|
+
let feedUrl;
|
|
662
|
+
try {
|
|
663
|
+
feedUrl = new URL(String(feed));
|
|
664
|
+
} catch {
|
|
665
|
+
console.error(`--feed url 无法解析:${feed}`);
|
|
666
|
+
return 1;
|
|
667
|
+
}
|
|
668
|
+
if (feedUrl.protocol !== 'https:') {
|
|
669
|
+
console.error(`--feed 必须 https(收到 ${feedUrl.protocol}//${feedUrl.host})`);
|
|
670
|
+
return 1;
|
|
671
|
+
}
|
|
672
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
673
|
+
let feedJson;
|
|
674
|
+
try {
|
|
675
|
+
const res = await fetchFn(feedUrl.href);
|
|
676
|
+
if (!res || !res.ok) throw new Error(`HTTP ${res?.status ?? '???'}`);
|
|
677
|
+
feedJson = JSON.parse(await res.text());
|
|
678
|
+
} catch (err) {
|
|
679
|
+
console.error(`feed 获取失败(${feedUrl.href}):${err?.message ?? err}`);
|
|
680
|
+
return 1;
|
|
681
|
+
}
|
|
682
|
+
const shapeErr = validateFeed(feedJson);
|
|
683
|
+
if (shapeErr !== null) {
|
|
684
|
+
console.error(`feed 形状不符:${shapeErr}`);
|
|
685
|
+
return 1;
|
|
686
|
+
}
|
|
687
|
+
const base = new URL('./', feedUrl).href;
|
|
688
|
+
let dl;
|
|
689
|
+
try {
|
|
690
|
+
dl = new URL(feedJson.url, base);
|
|
691
|
+
} catch {
|
|
692
|
+
console.error(`feed.url 无法按基址解析:${feedJson.url}(基址 ${base})`);
|
|
693
|
+
return 1;
|
|
694
|
+
}
|
|
695
|
+
console.log(`feed:v${feedJson.version}(${dl.href},${feedJson.size}B)`);
|
|
696
|
+
|
|
697
|
+
// 3. semver 比较(只认 >;等/低 no-op)
|
|
698
|
+
const due = targets.filter((t) => compareSemver(feedJson.version, t.version) > 0);
|
|
699
|
+
const fresh = targets.filter((t) => compareSemver(feedJson.version, t.version) <= 0);
|
|
700
|
+
for (const t of fresh) console.log(`已是最新(v${t.version}):${t.path}`);
|
|
701
|
+
if (due.length === 0) {
|
|
702
|
+
console.log('没有需要更新的安装。');
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
if (dryRun) {
|
|
706
|
+
for (const t of due) console.log(`[dry-run] 将更新:${t.path}(v${t.version} → v${feedJson.version})`);
|
|
707
|
+
console.log('dry-run:未写盘。');
|
|
708
|
+
return 0;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// 4. 逐目录应用(多命中全部应用;任一失败不回滚其它——如实逐条报告)
|
|
712
|
+
let failed = 0;
|
|
713
|
+
for (const t of due) {
|
|
714
|
+
try {
|
|
715
|
+
const r = await applyUpdate({
|
|
716
|
+
url: dl.href, sha256: feedJson.sha256, size: feedJson.size, destDir: t.path,
|
|
717
|
+
...(fetchImpl ? { fetchImpl } : {}), ...(sleepImpl ? { sleepImpl } : {}),
|
|
718
|
+
httpsOnly: true,
|
|
719
|
+
});
|
|
720
|
+
console.log(`已更新:${t.path}(v${r.from ?? '(无旧版本)'} → v${r.to})`);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
failed++;
|
|
723
|
+
console.error(`更新失败(${err?.code ?? 'io'}):${t.path}——${err?.message ?? err}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return failed > 0 ? 1 : 0;
|
|
727
|
+
}
|