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/package.json +1 -1
- package/src/cli.mjs +58 -9
- package/src/misc.mjs +2 -14
- package/src/nm/asset.mjs +288 -0
- package/src/nm/host.mjs +168 -16
- package/src/nm/update.mjs +727 -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
|
+
}
|