dsh-app-updater 1.3.2 → 1.3.4
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/CHANGELOG.md +293 -191
- package/CONTRACT.md +150 -150
- package/LICENSE +20 -20
- package/README.en.md +243 -239
- package/README.md +275 -253
- package/RELEASING.md +75 -75
- package/cordis.patch.yml +8 -8
- package/icon.svg +32 -32
- package/lib/client.js +1491 -1460
- package/lib/elevate.js +204 -204
- package/lib/engine.js +1315 -1282
- package/lib/index.js +597 -597
- package/lib/macos.js +494 -409
- package/lib/platform.js +199 -199
- package/lib/proc.js +91 -91
- package/lib/registry.js +120 -120
- package/lib/state.js +141 -141
- package/lib/version.js +81 -81
- package/lib/winget.js +337 -337
- package/package.json +68 -68
- package/test/deny.mjs +148 -148
- package/test/harness.mjs +125 -125
- package/test/render.mjs +613 -570
- package/test/unit.mjs +590 -552
package/lib/engine.js
CHANGED
|
@@ -1,1282 +1,1315 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 更新引擎:扫描 → 排队 → 逐个「下载官网全量安装包 → 校验 → 静默安装 → 复核版本」。
|
|
3
|
-
*
|
|
4
|
-
* 设计取舍:
|
|
5
|
-
* - 检测只用 winget 的公开子命令(list / upgrade),不需要启动任何被检测的软件;
|
|
6
|
-
* - 更新优先走 winget download:它把**官网原始安装包**完整下载到本地(不是增量补丁),
|
|
7
|
-
* 我们再自己用清单里的静默参数执行,做到「全量包 + 可留档 + 可回退」;
|
|
8
|
-
* - download 失败时退回 winget upgrade(同样是官网全量包,只是安装过程交给 winget);
|
|
9
|
-
* - 每步都写日志,失败原因原样透出,不吞错误。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { mkdir, readdir, stat, readFile, rm } from "node:fs/promises";
|
|
13
|
-
import { existsSync, lstatSync } from "node:fs";
|
|
14
|
-
import { join, basename, isAbsolute } from "node:path";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
|
-
|
|
17
|
-
import { runProcess, outputText } from "./proc.js";
|
|
18
|
-
import { elevationAvailable, isElevated, runElevated } from "./elevate.js";
|
|
19
|
-
import {
|
|
20
|
-
findWinget,
|
|
21
|
-
runWinget,
|
|
22
|
-
parseInstalledList,
|
|
23
|
-
parseUpgradeList,
|
|
24
|
-
parseWingetVersion,
|
|
25
|
-
isWingetUnavailable,
|
|
26
|
-
} from "./winget.js";
|
|
27
|
-
import { readRegistryApps, normalizeName } from "./registry.js";
|
|
28
|
-
import { compareVersions, isNewer, looksLikeVersion } from "./version.js";
|
|
29
|
-
import { detectEnvironment, describeEnvironment, engineFor, ENGINE_BREW, ENGINE_WINGET } from "./platform.js";
|
|
30
|
-
import { scanAppBundles, fetchCaskCatalog, parseCaskCatalog, buildMacosItems } from "./macos.js";
|
|
31
|
-
|
|
32
|
-
const INSTALLER_EXT = /\.(exe|msi|msix|appx|msixbundle|pkg|dmg)$/i;
|
|
33
|
-
|
|
34
|
-
/** 当前环境(进程级缓存:同一次运行里平台/架构不会变)。 */
|
|
35
|
-
let cachedEnv = null;
|
|
36
|
-
export function currentEnvironment() {
|
|
37
|
-
if (!cachedEnv) cachedEnv = detectEnvironment();
|
|
38
|
-
return cachedEnv;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/* ------------------------------------------------------------------ *
|
|
42
|
-
* 清单解析(只取我们需要的字段,不引第三方 YAML 库)
|
|
43
|
-
* ------------------------------------------------------------------ */
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 从 winget download 落盘的 merged manifest 里提取安装信息。
|
|
47
|
-
* 只做「按缩进扫描」的定向提取:InstallerType / Silent 参数 / Scope / InstallerUrl。
|
|
48
|
-
*/
|
|
49
|
-
export function parseInstallerManifest(yamlText) {
|
|
50
|
-
const text = String(yamlText || "").replace(/\r\n?/g, "\n");
|
|
51
|
-
const info = {
|
|
52
|
-
installerType: "",
|
|
53
|
-
installerUrl: "",
|
|
54
|
-
silent: "",
|
|
55
|
-
silentWithProgress: "",
|
|
56
|
-
scope: "",
|
|
57
|
-
elevation: "",
|
|
58
|
-
productCode: "",
|
|
59
|
-
};
|
|
60
|
-
const lines = text.split("\n");
|
|
61
|
-
let inInstallers = false;
|
|
62
|
-
let inSwitches = false;
|
|
63
|
-
let switchIndent = -1;
|
|
64
|
-
for (const rawLine of lines) {
|
|
65
|
-
const line = rawLine.replace(/\s+$/, "");
|
|
66
|
-
if (!line.trim()) continue;
|
|
67
|
-
const indent = line.length - line.trimStart().length;
|
|
68
|
-
const trimmed = line.trim();
|
|
69
|
-
if (/^Installers:\s*$/.test(trimmed)) {
|
|
70
|
-
inInstallers = true;
|
|
71
|
-
inSwitches = false;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (inInstallers && indent === 0 && /^[A-Za-z]/.test(trimmed)) {
|
|
75
|
-
// 回到顶层键:Installers 段结束
|
|
76
|
-
inInstallers = false;
|
|
77
|
-
inSwitches = false;
|
|
78
|
-
}
|
|
79
|
-
if (!inInstallers) continue;
|
|
80
|
-
|
|
81
|
-
if (/^InstallerSwitches:\s*$/.test(trimmed)) {
|
|
82
|
-
inSwitches = true;
|
|
83
|
-
switchIndent = indent;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
if (inSwitches && indent <= switchIndent && !/^InstallerSwitches:/.test(trimmed)) {
|
|
87
|
-
inSwitches = false;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (inSwitches) {
|
|
91
|
-
const m = trimmed.match(/^(SilentWithProgress|Silent|Log|InstallLocation):\s*(.+)$/);
|
|
92
|
-
if (m) {
|
|
93
|
-
const value = stripQuotes(m[2]);
|
|
94
|
-
if (m[1] === "Silent") info.silent = value;
|
|
95
|
-
else if (m[1] === "SilentWithProgress") info.silentWithProgress = value;
|
|
96
|
-
}
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const kv = trimmed.match(/^(InstallerType|InstallerUrl|Scope|ElevationRequirement|ProductCode|Architecture):\s*(.+)$/);
|
|
101
|
-
if (kv) {
|
|
102
|
-
const value = stripQuotes(kv[2]);
|
|
103
|
-
switch (kv[1]) {
|
|
104
|
-
case "InstallerType":
|
|
105
|
-
if (!info.installerType) info.installerType = value;
|
|
106
|
-
break;
|
|
107
|
-
case "InstallerUrl":
|
|
108
|
-
if (!info.installerUrl) info.installerUrl = value;
|
|
109
|
-
break;
|
|
110
|
-
case "Scope":
|
|
111
|
-
if (!info.scope) info.scope = value;
|
|
112
|
-
break;
|
|
113
|
-
case "ElevationRequirement":
|
|
114
|
-
if (!info.elevation) info.elevation = value;
|
|
115
|
-
break;
|
|
116
|
-
case "ProductCode":
|
|
117
|
-
if (!info.productCode) info.productCode = value;
|
|
118
|
-
break;
|
|
119
|
-
default:
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return info;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function stripQuotes(value) {
|
|
128
|
-
let v = String(value || "").trim();
|
|
129
|
-
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
|
|
130
|
-
v = v.slice(1, -1);
|
|
131
|
-
}
|
|
132
|
-
return v;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** 根据安装包类型给出静默安装参数(清单没写时的兜底)。 */
|
|
136
|
-
export function defaultSilentArgs(installerType, filePath) {
|
|
137
|
-
const type = String(installerType || "").toLowerCase();
|
|
138
|
-
const ext = String(filePath || "").toLowerCase();
|
|
139
|
-
if (type === "msi" || ext.endsWith(".msi")) return ["/qn", "/norestart"];
|
|
140
|
-
if (type === "inno" || type === "innosetup") return ["/SP-", "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"];
|
|
141
|
-
if (type === "nullsoft" || type === "nsis") return ["/S"];
|
|
142
|
-
if (type === "burn" || type === "wix") return ["/quiet", "/norestart"];
|
|
143
|
-
if (type === "msix" || type === "appx") return [];
|
|
144
|
-
// 未知 exe:优先 Inno 参数(国内/开源软件最常见),失败时由上层改用 winget upgrade
|
|
145
|
-
return ["/S"];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** 把清单里的 Silent 字符串切成参数数组(保留引号内的整体)。 */
|
|
149
|
-
export function splitSwitchArgs(silent) {
|
|
150
|
-
const text = String(silent || "").trim();
|
|
151
|
-
if (!text) return [];
|
|
152
|
-
const out = [];
|
|
153
|
-
let current = "";
|
|
154
|
-
let quote = null;
|
|
155
|
-
for (const ch of text) {
|
|
156
|
-
if (quote) {
|
|
157
|
-
if (ch === quote) quote = null;
|
|
158
|
-
else current += ch;
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
if (ch === '"' || ch === "'") {
|
|
162
|
-
quote = ch;
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (ch === " ") {
|
|
166
|
-
if (current) {
|
|
167
|
-
out.push(current);
|
|
168
|
-
current = "";
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
current += ch;
|
|
173
|
-
}
|
|
174
|
-
if (current) out.push(current);
|
|
175
|
-
return out;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* 决定用什么参数跑安装包。
|
|
180
|
-
*
|
|
181
|
-
* 顺序很重要:清单里声明的 Silent 参数优先(那是发布方自己给的、与安装包
|
|
182
|
-
* 完全匹配的一组开关);没有清单时才按安装包类型猜。
|
|
183
|
-
*
|
|
184
|
-
* 注意:清单里的 `/LOG="<LOGPATH>"` 是占位符模板,不能原样传,所以这里只
|
|
185
|
-
* 取 Silent / SilentWithProgress 两项,占位符再单独剔除。
|
|
186
|
-
*/
|
|
187
|
-
export function resolveInstallArgs(info, installerPath) {
|
|
188
|
-
const declared = (info && (info.silent || info.silentWithProgress)) || "";
|
|
189
|
-
// 占位符模板(<LOGPATH> / <INSTALLPATH>)不能原样传给安装包:删掉占位符,
|
|
190
|
-
// 再把因此变空值的开关(形如 /LOG=)整条剔除。
|
|
191
|
-
const cleaned = String(declared).replace(/<[A-Za-z_]+>/g, "").replace(/\s+/g, " ").trim();
|
|
192
|
-
const declaredArgs = splitSwitchArgs(cleaned).filter((a) => {
|
|
193
|
-
if (!a) return false;
|
|
194
|
-
if (/^\/?[A-Za-z]+=$/.test(a)) return false; // /LOG= 这类空值开关
|
|
195
|
-
return true;
|
|
196
|
-
});
|
|
197
|
-
if (declaredArgs.length) return declaredArgs;
|
|
198
|
-
return defaultSilentArgs(info && info.installerType, installerPath);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/* ------------------------------------------------------------------ *
|
|
202
|
-
* 扫描
|
|
203
|
-
* ------------------------------------------------------------------ */
|
|
204
|
-
|
|
205
|
-
export function resolveDownloadDir(settings) {
|
|
206
|
-
const custom = String((settings && settings.downloadDir) || "").trim();
|
|
207
|
-
if (custom && isAbsolute(custom)) return custom;
|
|
208
|
-
return join(homedir(), "Downloads", "DSH App Updater");
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function isStoreId(id) {
|
|
212
|
-
return /^(MSIX|MSSTORE)\\/i.test(String(id || ""));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* 执行一次完整扫描。
|
|
217
|
-
* @param {{ settings: object, log: (level: string, text: string) => void, signal?: AbortSignal }} ctx
|
|
218
|
-
*/
|
|
219
|
-
export async function scanApps({ settings, log, signal }) {
|
|
220
|
-
const startedAt = Date.now();
|
|
221
|
-
const warnings = [];
|
|
222
|
-
const env = currentEnvironment();
|
|
223
|
-
|
|
224
|
-
log("info", `运行环境:${describeEnvironment(env)}(引擎:${engineFor(env.family)})`);
|
|
225
|
-
|
|
226
|
-
if (env.family === "macos") {
|
|
227
|
-
return scanMacos({ settings, log, signal, env, startedAt, warnings });
|
|
228
|
-
}
|
|
229
|
-
if (env.family !== "windows") {
|
|
230
|
-
log("warn", `当前系统 ${env.platform} 没有可用的更新引擎,本插件支持 Windows 与 macOS`);
|
|
231
|
-
return {
|
|
232
|
-
env,
|
|
233
|
-
engine: { id: engineFor(env.family), available: false, version: "", path: "" },
|
|
234
|
-
scannedAt: Date.now(),
|
|
235
|
-
startedAt,
|
|
236
|
-
stats: { total: 0, upgradable: 0, unsupported: 0 },
|
|
237
|
-
items: [],
|
|
238
|
-
warnings: [`当前系统 ${env.platform} 暂不支持(本插件面向 Windows 与 macOS)`],
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const wingetPath = findWinget();
|
|
243
|
-
|
|
244
|
-
/** @type {Map<string, any>} */
|
|
245
|
-
const byId = new Map();
|
|
246
|
-
/** @type {Map<string, string>} id → available */
|
|
247
|
-
const availableById = new Map();
|
|
248
|
-
|
|
249
|
-
let wingetInfo = { available: false, version: "", path: wingetPath || "" };
|
|
250
|
-
if (wingetPath) {
|
|
251
|
-
const ver = await runWinget(wingetPath, ["--version"], { timeoutMs: 20000 });
|
|
252
|
-
const version = parseWingetVersion(ver.stdout);
|
|
253
|
-
wingetInfo = { available: ver.ok && !!version, version, path: wingetPath };
|
|
254
|
-
if (!wingetInfo.available) warnings.push("winget 无法执行,仅使用注册表检测(无法判断是否有新版本)");
|
|
255
|
-
} else {
|
|
256
|
-
warnings.push("未找到 winget.exe,仅使用注册表检测(无法判断是否有新版本)");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (wingetInfo.available) {
|
|
260
|
-
log("info", "正在读取 winget 已安装清单…");
|
|
261
|
-
const listRes = await runWinget(
|
|
262
|
-
wingetPath,
|
|
263
|
-
["list", "--accept-source-agreements", "--disable-interactivity"],
|
|
264
|
-
{ timeoutMs: 240000, signal }
|
|
265
|
-
);
|
|
266
|
-
if (!listRes.ok && isWingetUnavailable(listRes)) {
|
|
267
|
-
warnings.push(`winget list 失败:${firstLine(outputText(listRes)) || "未知错误"}`);
|
|
268
|
-
wingetInfo.available = false;
|
|
269
|
-
} else {
|
|
270
|
-
const installed = parseInstalledList(listRes.stdout);
|
|
271
|
-
log("info", `winget 报告 ${installed.length} 个已安装条目`);
|
|
272
|
-
for (const item of installed) {
|
|
273
|
-
if (!item.id) continue;
|
|
274
|
-
byId.set(item.id, {
|
|
275
|
-
id: item.id,
|
|
276
|
-
name: item.name || item.id,
|
|
277
|
-
version: item.version || "",
|
|
278
|
-
available: "",
|
|
279
|
-
publisher: "",
|
|
280
|
-
source: /^(MSIX|MSSTORE)\\/i.test(item.id) ? "msstore" : "winget",
|
|
281
|
-
installLocation: "",
|
|
282
|
-
hasUpdate: false,
|
|
283
|
-
updateSupported: false,
|
|
284
|
-
reason: null,
|
|
285
|
-
kind: null,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
log("info", "正在向软件目录查询可用更新…");
|
|
290
|
-
const upRes = await runWinget(
|
|
291
|
-
wingetPath,
|
|
292
|
-
["upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity"],
|
|
293
|
-
{ timeoutMs: 240000, signal }
|
|
294
|
-
);
|
|
295
|
-
if (upRes.ok || upRes.stdout) {
|
|
296
|
-
const upgradable = parseUpgradeList(upRes.stdout);
|
|
297
|
-
log("info", `软件目录报告 ${upgradable.length} 个可更新条目`);
|
|
298
|
-
for (const item of upgradable) {
|
|
299
|
-
if (!item.id) continue;
|
|
300
|
-
if (item.available) availableById.set(item.id, item.available);
|
|
301
|
-
const existing = byId.get(item.id);
|
|
302
|
-
if (existing) {
|
|
303
|
-
existing.available = item.available || "";
|
|
304
|
-
if (!existing.version && item.version) existing.version = item.version;
|
|
305
|
-
} else {
|
|
306
|
-
byId.set(item.id, {
|
|
307
|
-
id: item.id,
|
|
308
|
-
name: item.name || item.id,
|
|
309
|
-
version: item.version || "",
|
|
310
|
-
available: item.available || "",
|
|
311
|
-
publisher: "",
|
|
312
|
-
source: "winget",
|
|
313
|
-
installLocation: "",
|
|
314
|
-
hasUpdate: false,
|
|
315
|
-
updateSupported: false,
|
|
316
|
-
reason: null,
|
|
317
|
-
kind: null,
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
} else {
|
|
322
|
-
warnings.push("查询可用更新失败,本次只能显示已安装清单");
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// 注册表兜底:补齐 winget 目录未收录的应用,并给 winget 条目补上发布者/安装位置
|
|
328
|
-
if (settings.includeRegistryOnly) {
|
|
329
|
-
log("info", "正在读取注册表已安装应用…");
|
|
330
|
-
const reg = await readRegistryApps();
|
|
331
|
-
if (!reg.ok) {
|
|
332
|
-
warnings.push(`注册表读取失败:${reg.error || "未知错误"}`);
|
|
333
|
-
} else {
|
|
334
|
-
log("info", `注册表报告 ${reg.apps.length} 个应用`);
|
|
335
|
-
const nameIndex = new Map();
|
|
336
|
-
for (const app of byId.values()) nameIndex.set(normalizeName(app.name), app);
|
|
337
|
-
for (const r of reg.apps) {
|
|
338
|
-
const key = normalizeName(r.name);
|
|
339
|
-
const matched = key ? nameIndex.get(key) : null;
|
|
340
|
-
if (matched) {
|
|
341
|
-
if (!matched.publisher && r.publisher) matched.publisher = r.publisher;
|
|
342
|
-
if (!matched.installLocation && r.location) matched.installLocation = r.location;
|
|
343
|
-
if (!matched.version && r.version) matched.version = r.version;
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
const id = `reg:${r.key || key || r.name}`;
|
|
347
|
-
if (byId.has(id)) continue;
|
|
348
|
-
byId.set(id, {
|
|
349
|
-
id,
|
|
350
|
-
name: r.name,
|
|
351
|
-
version: r.version || "",
|
|
352
|
-
available: "",
|
|
353
|
-
publisher: r.publisher || "",
|
|
354
|
-
source: "registry",
|
|
355
|
-
installLocation: r.location || "",
|
|
356
|
-
hasUpdate: false,
|
|
357
|
-
updateSupported: false,
|
|
358
|
-
reason: null,
|
|
359
|
-
kind: null,
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// 汇总判定
|
|
366
|
-
const items = [];
|
|
367
|
-
for (const app of byId.values()) {
|
|
368
|
-
if (!settings.includeStoreApps && app.source === "msstore") continue;
|
|
369
|
-
if (!settings.includeRegistryOnly && app.source === "registry") continue;
|
|
370
|
-
const available = app.available || availableById.get(app.id) || "";
|
|
371
|
-
let hasUpdate = false;
|
|
372
|
-
let reason = null;
|
|
373
|
-
let updateSupported = false;
|
|
374
|
-
|
|
375
|
-
if (isStoreId(app.id) || app.source === "msstore") {
|
|
376
|
-
reason = "store-managed";
|
|
377
|
-
} else if (!available) {
|
|
378
|
-
reason = app.source === "registry" ? "not-in-catalog" : null;
|
|
379
|
-
} else if (!looksLikeVersion(app.version)) {
|
|
380
|
-
// winget 显示 Unknown:无法确认是否真的更新,按可更新处理但不静默判定
|
|
381
|
-
hasUpdate = true;
|
|
382
|
-
updateSupported = true;
|
|
383
|
-
} else if (isNewer(available, app.version)) {
|
|
384
|
-
hasUpdate = true;
|
|
385
|
-
updateSupported = true;
|
|
386
|
-
} else {
|
|
387
|
-
hasUpdate = false;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (hasUpdate && !updateSupported && !reason) reason = "unknown-version";
|
|
391
|
-
items.push({
|
|
392
|
-
...app,
|
|
393
|
-
available: hasUpdate ? available : "",
|
|
394
|
-
hasUpdate,
|
|
395
|
-
updateSupported,
|
|
396
|
-
reason: hasUpdate && updateSupported ? null : reason,
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
items.sort((a, b) => {
|
|
401
|
-
if (a.hasUpdate !== b.hasUpdate) return a.hasUpdate ? -1 : 1;
|
|
402
|
-
if (a.updateSupported !== b.updateSupported) return a.updateSupported ? -1 : 1;
|
|
403
|
-
return String(a.name).localeCompare(String(b.name), "zh-Hans-CN");
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
const stats = {
|
|
407
|
-
total: items.length,
|
|
408
|
-
upgradable: items.filter((i) => i.hasUpdate && i.updateSupported).length,
|
|
409
|
-
unsupported: items.filter((i) => i.hasUpdate && !i.updateSupported).length,
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
log("success", `扫描完成:${stats.total} 个应用,${stats.upgradable} 个可更新`);
|
|
413
|
-
|
|
414
|
-
return {
|
|
415
|
-
env,
|
|
416
|
-
engine: {
|
|
417
|
-
id: ENGINE_WINGET,
|
|
418
|
-
available: Boolean(wingetInfo.available),
|
|
419
|
-
version: wingetInfo.version || "",
|
|
420
|
-
path: wingetInfo.path || "",
|
|
421
|
-
},
|
|
422
|
-
winget: wingetInfo,
|
|
423
|
-
scannedAt: Date.now(),
|
|
424
|
-
startedAt,
|
|
425
|
-
stats,
|
|
426
|
-
items,
|
|
427
|
-
warnings,
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/* ------------------------------------------------------------------ *
|
|
432
|
-
* 提权、复核与「自带更新服务」
|
|
433
|
-
* ------------------------------------------------------------------ */
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* winget 报「清单里没有可用安装包」一类错误(本机架构没有对应安装包)。
|
|
437
|
-
* 中文输出是「找不到适用的安装程序」,英文是 “No applicable installer found”。
|
|
438
|
-
*
|
|
439
|
-
* 这类失败必须退回「不限架构」再来一次:虎牙直播(Huya.Huya)的清单里只有
|
|
440
|
-
* 一个不标架构的通用 exe,带上 `--architecture x64` 就永远找不到。
|
|
441
|
-
*/
|
|
442
|
-
export function isArchNotFoundError(text) {
|
|
443
|
-
const raw = String(text || "");
|
|
444
|
-
if (!raw) return false;
|
|
445
|
-
return /architecture|架构|applicable installer|not applicable|找不到适用|没有适用/i.test(raw);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** 安装器退出码 → 人话。输出为空时兜底;MSI 的真实原因在它自己的日志里。 */
|
|
449
|
-
export function explainInstallerExitCode(code, text) {
|
|
450
|
-
const first = firstLine(text);
|
|
451
|
-
if (first) return first;
|
|
452
|
-
const map = {
|
|
453
|
-
1603: "安装程序返回 1603(安装失败:常见原因是权限不足、文件被占用或已装其它版本;MSI 详情见日志)",
|
|
454
|
-
1619: "安装程序返回 1619(安装包无法打开)",
|
|
455
|
-
1620: "安装程序返回 1620(安装包损坏或不可读)",
|
|
456
|
-
1625: "安装程序返回 1625(被系统策略禁止)",
|
|
457
|
-
1638: "安装程序返回 1638(已安装其它版本)",
|
|
458
|
-
1730: "安装程序返回 1730(卸载/安装需要管理员权限)",
|
|
459
|
-
};
|
|
460
|
-
return map[code] || `安装程序退出码 ${code}`;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* 判断一次更新是否真的生效。
|
|
465
|
-
*
|
|
466
|
-
* 只看安装器退出码是不够的:机器级安装包在没有管理员令牌时会**静默退出 0**
|
|
467
|
-
* 却什么都没装(实测 Logitech G HUB 2026.6.981214 → 目标 2026.7.992920,
|
|
468
|
-
* 退出码 0,Program Files 里的文件与 winget 报的版本都没有变化)。
|
|
469
|
-
* 所以拿「安装前版本 / 安装后版本 / 目标版本」三个值判断,判不出来才算失败。
|
|
470
|
-
*
|
|
471
|
-
* @returns {{changed: boolean, reason: string}}
|
|
472
|
-
*/
|
|
473
|
-
export function evaluateUpdateResult({ before, after, target }) {
|
|
474
|
-
const a = String(after == null ? "" : after).trim();
|
|
475
|
-
const b = String(before == null ? "" : before).trim();
|
|
476
|
-
const t = String(target == null ? "" : target).trim();
|
|
477
|
-
// winget 读不到已装版本(虎牙直播的卸载信息里就没有版本号)→ 无法复核:
|
|
478
|
-
// 既不能判生效也不能判失败,交给调用方按「已安装、未复核」处理。
|
|
479
|
-
if (!looksLikeVersion(a)) {
|
|
480
|
-
return {
|
|
481
|
-
changed: false,
|
|
482
|
-
unverifiable: true,
|
|
483
|
-
reason: a ? `winget 读不到已装版本(显示为 ${a})` : "安装后读不到版本",
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
// 达到或超过目标版本 → 生效
|
|
487
|
-
if (t && !isNewer(t, a)) {
|
|
488
|
-
return { changed: true, reason: `版本已是 ${a}(目标 ${t})` };
|
|
489
|
-
}
|
|
490
|
-
// 目标版本未知时,只能看它是否真的变新了
|
|
491
|
-
if (b && looksLikeVersion(b) && isNewer(a, b)) {
|
|
492
|
-
return { changed: true, reason: `版本从 ${b} 升到 ${a}` };
|
|
493
|
-
}
|
|
494
|
-
if (t) return { changed: false, reason: `版本没有变化(仍为 ${a},目标 ${t})` };
|
|
495
|
-
return { changed: false, reason: `版本没有变化(仍为 ${a})` };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/** 可取消的等待。 */
|
|
499
|
-
function delay(ms, signal) {
|
|
500
|
-
return new Promise((resolve) => {
|
|
501
|
-
if (signal && signal.aborted) {
|
|
502
|
-
resolve();
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const finish = () => {
|
|
506
|
-
clearTimeout(timer);
|
|
507
|
-
if (signal) signal.removeEventListener("abort", finish);
|
|
508
|
-
resolve();
|
|
509
|
-
};
|
|
510
|
-
const timer = setTimeout(finish, ms);
|
|
511
|
-
if (signal) signal.addEventListener("abort", finish, { once: true });
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/** 提权跑 winget:winget 自己的安装动作同样需要管理员令牌才能装机器级包。 */
|
|
516
|
-
async function runWingetElevated(wingetPath, args, opts = {}) {
|
|
517
|
-
const res = await runElevated(wingetPath, args, { timeoutMs: opts.timeoutMs, signal: opts.signal });
|
|
518
|
-
return {
|
|
519
|
-
ok: res.ok,
|
|
520
|
-
code: res.code,
|
|
521
|
-
stdout: res.stdout || "",
|
|
522
|
-
stderr: "",
|
|
523
|
-
error: res.spawnError ? "spawn" : res.timedOut ? "timeout" : null,
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* 本轮的「目标版本」表:复核「到底升没升上去」的依据。
|
|
529
|
-
* 用一次 `winget upgrade --include-unknown` 拿全量(含已装版本为 Unknown 的应用)。
|
|
530
|
-
*/
|
|
531
|
-
async function fetchUpgradeTargets(wingetPath, ctx, signal) {
|
|
532
|
-
const map = new Map();
|
|
533
|
-
if (!wingetPath) return map;
|
|
534
|
-
try {
|
|
535
|
-
const res = await ctx.runWinget(
|
|
536
|
-
wingetPath,
|
|
537
|
-
["upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity"],
|
|
538
|
-
{ timeoutMs: 240000, signal }
|
|
539
|
-
);
|
|
540
|
-
if (res.ok) {
|
|
541
|
-
for (const row of parseUpgradeList(res.stdout)) {
|
|
542
|
-
const key = String(row.id || "").toLowerCase();
|
|
543
|
-
if (key && row.available) map.set(key, row.available);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
} catch {
|
|
547
|
-
// 拿不到目标版本时退化为「只看版本有没有变化」
|
|
548
|
-
}
|
|
549
|
-
return map;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* 自带更新服务的软件:winget 目录里给的是**企业版**安装包,与本机的消费版
|
|
554
|
-
* 安装技术不匹配,winget 会直接拒绝(0x8A15008E「安装技术与当前安装的版本
|
|
555
|
-
* 不同,请卸载包并安装较新版本」)。这类软件交给它自己的更新服务。
|
|
556
|
-
*/
|
|
557
|
-
const SELF_UPDATERS = new Map([
|
|
558
|
-
["microsoft.edge", "edgeupdate"],
|
|
559
|
-
["microsoft.edgewebview2runtime", "edgeupdate"],
|
|
560
|
-
["microsoft.edgewebview", "edgeupdate"],
|
|
561
|
-
]);
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* 少数软件的更新不由安装包完成,而是由它自己的常驻更新器在**应用启动时**应用
|
|
565
|
-
* (实测 Logitech G HUB:更新包早就下载好躺在 %ProgramData%\LGHUB\next.json 指向的
|
|
566
|
-
* depots 里,安装器 `--silent` 和 winget upgrade 都退出 0 什么都不做,只有启动
|
|
567
|
-
* G HUB 本体后 current.json 才跳到新版本)。
|
|
568
|
-
*
|
|
569
|
-
* 插件承诺「不需要打开被检测的软件」,所以这里只给提示,不替用户启动 app。
|
|
570
|
-
*/
|
|
571
|
-
const UPDATE_HINTS = new Map([
|
|
572
|
-
[
|
|
573
|
-
"logitech.ghub",
|
|
574
|
-
"G HUB 的更新由它自己的更新器在「启动 G HUB」时应用:安装包与 winget 都可能退出 0 而不生效,手动打开一次 G HUB 即可完成升级(插件不会替你启动软件)",
|
|
575
|
-
],
|
|
576
|
-
[
|
|
577
|
-
"microsoft.edge",
|
|
578
|
-
"Edge 由 EdgeUpdate 服务维护;该服务不可用时插件会退回安装官网全量包(企业版 MSI,提权执行)",
|
|
579
|
-
],
|
|
580
|
-
]);
|
|
581
|
-
|
|
582
|
-
/** 这条应用有没有「安装之外的更新方式」提示。 */
|
|
583
|
-
export function updateHintFor(id) {
|
|
584
|
-
return UPDATE_HINTS.get(String(id || "").toLowerCase()) || "";
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/** 定位 Microsoft Edge 自带的更新服务。 */
|
|
588
|
-
export function findEdgeUpdate(env = process.env) {
|
|
589
|
-
const roots = [
|
|
590
|
-
env["ProgramFiles(x86)"],
|
|
591
|
-
env.ProgramFiles,
|
|
592
|
-
"C:\\Program Files (x86)",
|
|
593
|
-
"C:\\Program Files",
|
|
594
|
-
].filter(Boolean);
|
|
595
|
-
for (const root of roots) {
|
|
596
|
-
const file = join(root, "Microsoft", "EdgeUpdate", "MicrosoftEdgeUpdate.exe");
|
|
597
|
-
try {
|
|
598
|
-
if (existsSync(file)) return file;
|
|
599
|
-
} catch {
|
|
600
|
-
// 受保护目录读不到时继续找下一个
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return null;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* 触发自带更新服务(目前只有 Edge)。
|
|
608
|
-
* 它的退出码不代表版本已经就位(只是把任务交给服务),所以交给 verifyInstalled 轮询确认。
|
|
609
|
-
*/
|
|
610
|
-
async function runSelfUpdater(kind, log, signal, ctx) {
|
|
611
|
-
if (kind !== "edgeupdate") return { ok: false, error: "未知的自带更新方式" };
|
|
612
|
-
const exe = findEdgeUpdate();
|
|
613
|
-
if (!exe) return { ok: false, error: "未找到 MicrosoftEdgeUpdate.exe(Edge 自带更新服务)" };
|
|
614
|
-
log("info", "改用 Edge 自带更新服务(winget 目录里是企业版 MSI,与本机消费版 Edge 的安装技术不匹配)");
|
|
615
|
-
const res = await ctx.run(exe, ["/ua"], { timeoutMs: 30 * 60 * 1000, signal });
|
|
616
|
-
if (res.spawnError) return { ok: false, error: res.spawnError };
|
|
617
|
-
if (res.timedOut) return { ok: false, error: "Edge 自带更新服务超时" };
|
|
618
|
-
return { ok: true, note: "已交给 Microsoft Edge 更新服务,等待版本就位" };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* 安装后复核:读已装版本,与目标/安装前版本比对,必要时轮询等待
|
|
623
|
-
* (Edge 这类自带更新服务不是同步生效的)。
|
|
624
|
-
*/
|
|
625
|
-
async function verifyInstalled({ wingetPath, id, before, target, signal, log, attempts = 1, delayMs = 15000 }) {
|
|
626
|
-
let version = "";
|
|
627
|
-
let verdict = { changed: false, reason: "尚未复核" };
|
|
628
|
-
const rounds = Math.max(1, attempts);
|
|
629
|
-
for (let i = 0; i < rounds; i += 1) {
|
|
630
|
-
if (signal && signal.aborted) break;
|
|
631
|
-
version = await readInstalledVersion(wingetPath, id, signal);
|
|
632
|
-
verdict = evaluateUpdateResult({ before, after: version, target });
|
|
633
|
-
if (verdict.changed || verdict.unverifiable) return { ...verdict, version };
|
|
634
|
-
if (i + 1 < rounds) {
|
|
635
|
-
if (log && i === 0) {
|
|
636
|
-
log("info", `${id}:暂未读到新版本(${version || "未知"}),继续等待…`);
|
|
637
|
-
}
|
|
638
|
-
await delay(delayMs, signal);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
return { ...verdict, version };
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function firstLine(text) {
|
|
645
|
-
const line = String(text || "").trim().split("\n")[0] || "";
|
|
646
|
-
return line.slice(0, 200);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/* ------------------------------------------------------------------ *
|
|
650
|
-
* 更新
|
|
651
|
-
* ------------------------------------------------------------------ */
|
|
652
|
-
|
|
653
|
-
/** 目录快照,用于在 winget download 后找出新增文件。 */
|
|
654
|
-
async function snapshotDir(dir) {
|
|
655
|
-
const map = new Map();
|
|
656
|
-
try {
|
|
657
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
658
|
-
for (const e of entries) {
|
|
659
|
-
if (!e.isFile()) continue;
|
|
660
|
-
try {
|
|
661
|
-
const st = await stat(join(dir, e.name));
|
|
662
|
-
map.set(e.name, st.mtimeMs);
|
|
663
|
-
} catch {
|
|
664
|
-
/* 文件在扫描间隙被删掉 */
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
} catch {
|
|
668
|
-
/* 目录不存在 */
|
|
669
|
-
}
|
|
670
|
-
return map;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* 下载官网全量安装包。
|
|
675
|
-
* @returns {Promise<{ok:boolean, installer?:string, manifest?:string, info?:object, error?:string}>}
|
|
676
|
-
*/
|
|
677
|
-
async function downloadInstaller(wingetPath, id, dir, log, signal, opts = {}) {
|
|
678
|
-
await mkdir(dir, { recursive: true });
|
|
679
|
-
const before = await snapshotDir(dir);
|
|
680
|
-
const env = opts.env || currentEnvironment();
|
|
681
|
-
const archFlag = opts.matchArch === false ? "" : env.wingetArch || "";
|
|
682
|
-
if (archFlag) log("info", `按本机架构选择安装包:${env.archLabel}(--architecture ${archFlag})`);
|
|
683
|
-
else log("info", `未指定架构,交给 winget 自动匹配(${env.archLabel})`);
|
|
684
|
-
log("info", `下载官网全量安装包:${id}`);
|
|
685
|
-
const args = [
|
|
686
|
-
"download",
|
|
687
|
-
"--id",
|
|
688
|
-
id,
|
|
689
|
-
"--download-directory",
|
|
690
|
-
dir,
|
|
691
|
-
"--accept-package-agreements",
|
|
692
|
-
"--accept-source-agreements",
|
|
693
|
-
"--disable-interactivity",
|
|
694
|
-
];
|
|
695
|
-
if (archFlag) args.push("--architecture", archFlag);
|
|
696
|
-
const res = await runWinget(wingetPath, args, { timeoutMs: 30 * 60 * 1000, signal });
|
|
697
|
-
const text = outputText(res);
|
|
698
|
-
if (!res.ok) {
|
|
699
|
-
const err = classifyWingetError(text) || `winget download 退出码 ${res.code}`;
|
|
700
|
-
// 指定架构失败(清单里没有这一架构的安装包)时,退回让 winget 自己挑,并如实记录
|
|
701
|
-
if (archFlag && isArchNotFoundError(text)) {
|
|
702
|
-
log("warn", `${id}:清单里没有 ${archFlag} 架构的安装包,改为自动匹配`);
|
|
703
|
-
return downloadInstaller(wingetPath, id, dir, log, signal, { ...opts, matchArch: false });
|
|
704
|
-
}
|
|
705
|
-
return { ok: false, error: err };
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// 从输出里取落盘路径;取不到就按目录差异找
|
|
709
|
-
let installer = null;
|
|
710
|
-
const m = text.match(/Installer downloaded:\s*(.+?)\s*$/m);
|
|
711
|
-
if (m) {
|
|
712
|
-
const candidate = m[1].trim().replace(/^["']|["']$/g, "");
|
|
713
|
-
installer = isAbsolute(candidate) ? candidate : join(dir, basename(candidate));
|
|
714
|
-
}
|
|
715
|
-
if (!installer) {
|
|
716
|
-
const after = await snapshotDir(dir);
|
|
717
|
-
const fresh = [];
|
|
718
|
-
for (const [name, mtime] of after) {
|
|
719
|
-
if (!before.has(name) || before.get(name) !== mtime) fresh.push(name);
|
|
720
|
-
}
|
|
721
|
-
const pick = fresh.find((n) => INSTALLER_EXT.test(n)) || fresh.find((n) => /\.ya?ml$/i.test(n));
|
|
722
|
-
if (pick) installer = join(dir, pick);
|
|
723
|
-
}
|
|
724
|
-
if (!installer) return { ok: false, error: "下载完成但没找到安装包文件" };
|
|
725
|
-
|
|
726
|
-
let manifest = null;
|
|
727
|
-
const manifestGuess = installer.replace(INSTALLER_EXT, ".yaml");
|
|
728
|
-
try {
|
|
729
|
-
const st = await stat(manifestGuess);
|
|
730
|
-
if (st.isFile()) manifest = manifestGuess;
|
|
731
|
-
} catch {
|
|
732
|
-
const after = await snapshotDir(dir);
|
|
733
|
-
const yaml = [...after.keys()].find((n) => /\.ya?ml$/i.test(n) && n.includes(basename(installer).split("_")[0]));
|
|
734
|
-
if (yaml) manifest = join(dir, yaml);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
let info = {};
|
|
738
|
-
if (manifest) {
|
|
739
|
-
try {
|
|
740
|
-
info = parseInstallerManifest(await readFile(manifest, "utf8"));
|
|
741
|
-
} catch {
|
|
742
|
-
info = {};
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return { ok: true, installer, manifest, info };
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/** 执行安装包(静默)。 */
|
|
749
|
-
async function runInstaller(installer, info, log, signal, ctx = {}) {
|
|
750
|
-
const ext = String(installer).toLowerCase();
|
|
751
|
-
const isMsi = ext.endsWith(".msi");
|
|
752
|
-
const run = ctx.run || runProcess;
|
|
753
|
-
const args = resolveInstallArgs(info, installer);
|
|
754
|
-
const exe = isMsi ? join(process.env.SystemRoot || "C:\\Windows", "System32", "msiexec.exe") : installer;
|
|
755
|
-
// 安装失败时唯一能说明原因的现场就是 MSI 自己的日志(Error 1925 / 1603 …),
|
|
756
|
-
// 所以顺手开一份;日志路径由调用方提供一个可写目录(临时目录里没有空格)。
|
|
757
|
-
const msiLog = isMsi && ctx.scratchDir
|
|
758
|
-
? join(ctx.scratchDir, `${basename(installer).replace(/[^\w.-]+/g, "_")}.msi.log`)
|
|
759
|
-
: "";
|
|
760
|
-
const finalArgs = isMsi ? ["/i", installer, ...args, ...(msiLog ? ["/l*v", msiLog] : [])] : args;
|
|
761
|
-
log("info", `静默安装:${basename(installer)} ${finalArgs.join(" ")}`.trim());
|
|
762
|
-
if (ctx.elevated) log("info", "安装步骤在管理员上下文中执行(自动提权)");
|
|
763
|
-
const res = await run(exe, finalArgs, { timeoutMs: 60 * 60 * 1000, signal });
|
|
764
|
-
const text = outputText(res);
|
|
765
|
-
if (res.spawnError) return { ok: false, error: res.spawnError, code: null };
|
|
766
|
-
if (res.timedOut) return { ok: false, error: "安装超时(60 分钟)", code: null };
|
|
767
|
-
if (res.code !== 0) {
|
|
768
|
-
// 3010 = 需要重启,属成功
|
|
769
|
-
if (res.code === 3010) return { ok: true, code: res.code, note: "安装完成,需重启生效" };
|
|
770
|
-
const detail = msiLog && existsSync(msiLog) ? `;MSI 日志:${msiLog}` : "";
|
|
771
|
-
return { ok: false, error: explainInstallerExitCode(res.code, text) + detail, code: res.code };
|
|
772
|
-
}
|
|
773
|
-
return { ok: true, code: 0 };
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* 用 winget upgrade 兜底(同样是官网全量包,安装由 winget 执行)。
|
|
778
|
-
*
|
|
779
|
-
* 两道修正:
|
|
780
|
-
* 1) 带上 `--include-unknown`:已装版本被 winget 读成 Unknown 的应用(虎牙直播)
|
|
781
|
-
* 不带它就直接「目录里没有适用的更新」;
|
|
782
|
-
* 2) 带架构过滤而失败时退回不限架构再试一次(清单里只有通用 exe 的应用)。
|
|
783
|
-
*/
|
|
784
|
-
async function wingetUpgradeFallback(wingetPath, id, log, signal, opts = {}) {
|
|
785
|
-
const env = opts.env || currentEnvironment();
|
|
786
|
-
const run = opts.run || runWinget;
|
|
787
|
-
const archFlag = opts.matchArch === false ? "" : env.wingetArch || "";
|
|
788
|
-
|
|
789
|
-
const attempt = async (flag) => {
|
|
790
|
-
log("warn", `改用 winget upgrade 兜底:${id}(${flag ? "--architecture " + flag : "不限架构"})`);
|
|
791
|
-
const args = [
|
|
792
|
-
"upgrade",
|
|
793
|
-
"--id",
|
|
794
|
-
id,
|
|
795
|
-
"--exact",
|
|
796
|
-
"--silent",
|
|
797
|
-
"--include-unknown",
|
|
798
|
-
"--accept-package-agreements",
|
|
799
|
-
"--accept-source-agreements",
|
|
800
|
-
"--disable-interactivity",
|
|
801
|
-
];
|
|
802
|
-
if (flag) args.push("--architecture", flag);
|
|
803
|
-
const res = await run(wingetPath, args, { timeoutMs: 60 * 60 * 1000, signal });
|
|
804
|
-
return { res, text: outputText(res) };
|
|
805
|
-
};
|
|
806
|
-
|
|
807
|
-
let { res, text } = await attempt(archFlag);
|
|
808
|
-
if (!res.ok && archFlag && isArchNotFoundError(text)) {
|
|
809
|
-
// 清单里没有本机架构的安装包 → 交回 winget 自己挑
|
|
810
|
-
({ res, text } = await attempt(""));
|
|
811
|
-
}
|
|
812
|
-
if (res.ok) return { ok: true };
|
|
813
|
-
const code = res.code;
|
|
814
|
-
if (code === 3010) return { ok: true, note: "安装完成,需重启生效" };
|
|
815
|
-
if ((code >>> 0) === 0x8a15002b) return { ok: false, error: "目录里没有适用的更新" };
|
|
816
|
-
return { ok: false, error: classifyWingetError(text) || `winget upgrade 退出码 ${code}` };
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/** 安装后复核版本。 */
|
|
820
|
-
async function readInstalledVersion(wingetPath, id, signal) {
|
|
821
|
-
if (!wingetPath || id.startsWith("reg:")) return "";
|
|
822
|
-
const res = await runWinget(
|
|
823
|
-
wingetPath,
|
|
824
|
-
["list", "--id", id, "--exact", "--accept-source-agreements", "--disable-interactivity"],
|
|
825
|
-
{ timeoutMs: 60000, signal }
|
|
826
|
-
);
|
|
827
|
-
const rows = parseInstalledList(res.stdout);
|
|
828
|
-
const row = rows.find((r) => r.id.toLowerCase() === id.toLowerCase()) || rows[0];
|
|
829
|
-
return row ? row.version : "";
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function classifyWingetError(text) {
|
|
833
|
-
const raw = String(text || "");
|
|
834
|
-
if (!raw.trim()) return "";
|
|
835
|
-
if (/hash does not match|哈希不匹配|Installer hash/i.test(raw)) {
|
|
836
|
-
return "官网安装包的哈希与软件目录记录不一致(发布方更新了安装包),可改用 winget upgrade 兜底,或以管理员身份执行 winget settings --enable InstallerHashOverride 后重试";
|
|
837
|
-
}
|
|
838
|
-
if (/DownloadCommandProhibited/i.test(raw)) return "该软件的清单禁止直接下载,只能通过 winget upgrade 安装";
|
|
839
|
-
if (/requires administrator|需要管理员|elevat/i.test(raw)) return "需要管理员权限(会弹出 UAC 确认)";
|
|
840
|
-
if (/No available upgrade found|No newer package/i.test(raw)) return "目录里没有适用的更新";
|
|
841
|
-
if (/not found|找不到|No package found/i.test(raw)) return "软件目录里找不到该包";
|
|
842
|
-
if (/被拒绝访问|Access is denied/i.test(raw)) return "访问被拒绝(可能需要以管理员身份运行)";
|
|
843
|
-
const line = raw.split("\n").map((l) => l.trim()).filter((l) => l && !/^[-=]+$/.test(l)).pop() || "";
|
|
844
|
-
return line.slice(0, 300);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* 串行执行一批更新。
|
|
849
|
-
* @param {object} p
|
|
850
|
-
* @param {string[]} p.ids
|
|
851
|
-
* @param {object} p.settings
|
|
852
|
-
* @param {(level: string, text: string) => void} p.log
|
|
853
|
-
* @param {(patch: object) => void} p.onProgress 进度回调(写进 state.update)
|
|
854
|
-
* @param {AbortSignal} p.signal
|
|
855
|
-
*/
|
|
856
|
-
export async function runUpdates({ ids, settings, log, onProgress, signal, plan = [] }) {
|
|
857
|
-
const env = currentEnvironment();
|
|
858
|
-
const matchArch = settings.matchSystemArch !== false;
|
|
859
|
-
|
|
860
|
-
if (env.family === "macos") {
|
|
861
|
-
return runMacosUpdates({ ids, settings, log, onProgress, signal, env });
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
const wingetPath = findWinget();
|
|
865
|
-
const dir = resolveDownloadDir(settings);
|
|
866
|
-
const results = [];
|
|
867
|
-
const queue = [...new Set(ids.filter(Boolean))];
|
|
868
|
-
const planById = new Map(
|
|
869
|
-
(Array.isArray(plan) ? plan : [])
|
|
870
|
-
.map((item) => [String((item && item.id) || "").toLowerCase(), item])
|
|
871
|
-
.filter(([key]) => key)
|
|
872
|
-
);
|
|
873
|
-
|
|
874
|
-
// 提权:机器级安装(MSI / 写 Program Files)需要管理员令牌,而进程令牌创建后无法
|
|
875
|
-
// 原地提升,所以安装类命令交给提权副本执行(lib/elevate.js)。读版本、下载都不需要。
|
|
876
|
-
const autoElevate = settings.autoElevate !== false;
|
|
877
|
-
const elevatedNow = env.family === "windows" ? await isElevated({ signal }) : true;
|
|
878
|
-
const useElevation = env.family === "windows" && autoElevate && !elevatedNow && elevationAvailable();
|
|
879
|
-
const ctx = {
|
|
880
|
-
run: (exe, args, o) => (useElevation ? runElevated(exe, args, o) : runProcess(exe, args, o)),
|
|
881
|
-
runWinget: (exe, args, o) => (useElevation ? runWingetElevated(exe, args, o) : runWinget(exe, args, o)),
|
|
882
|
-
elevated: useElevation,
|
|
883
|
-
scratchDir: dir,
|
|
884
|
-
};
|
|
885
|
-
|
|
886
|
-
onProgress({ running: true, total: queue.length, done: 0, current: null, results: [], error: null, startedAt: Date.now() });
|
|
887
|
-
log("info", `开始更新 ${queue.length} 个应用(${describeEnvironment(env)},按架构匹配:${matchArch ? "是" : "否"})`);
|
|
888
|
-
if (env.family === "windows") {
|
|
889
|
-
if (elevatedNow) log("info", "当前进程已具备管理员权限");
|
|
890
|
-
else if (useElevation) log("info", "当前进程没有管理员权限:需要管理员的安装步骤会自动提权执行");
|
|
891
|
-
else if (autoElevate) log("warn", "当前进程没有管理员权限,且找不到 powershell.exe:无法自动提权,机器级安装会失败");
|
|
892
|
-
else log("warn", "当前进程没有管理员权限,且已关闭「自动提权安装」:机器级安装会失败(MSI 报 1925、安装器静默退出 0)");
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// 「目标版本」表:安装后复核「到底升没升上去」的依据
|
|
896
|
-
const targets = await fetchUpgradeTargets(wingetPath, ctx, signal);
|
|
897
|
-
|
|
898
|
-
let index = 0;
|
|
899
|
-
for (const id of queue) {
|
|
900
|
-
if (signal.aborted) {
|
|
901
|
-
results.push({ id, ok: false, error: "已取消" });
|
|
902
|
-
log("warn", `已取消:${id}`);
|
|
903
|
-
break;
|
|
904
|
-
}
|
|
905
|
-
index += 1;
|
|
906
|
-
onProgress({ running: true, total: queue.length, done: index - 1, current: id });
|
|
907
|
-
log("info", `(${index}/${queue.length})开始处理 ${id}`);
|
|
908
|
-
|
|
909
|
-
const startedAt = Date.now();
|
|
910
|
-
const target = targets.get(id.toLowerCase()) || "";
|
|
911
|
-
const before = (planById.get(id.toLowerCase()) || {}).version || "";
|
|
912
|
-
let entry = { id, ok: false, error: null, installer: null, fromCache: false, note: null, verified: null, durationMs: 0 };
|
|
913
|
-
|
|
914
|
-
try {
|
|
915
|
-
const selfUpdater = SELF_UPDATERS.get(id.toLowerCase());
|
|
916
|
-
let settled = false;
|
|
917
|
-
|
|
918
|
-
if (!wingetPath) {
|
|
919
|
-
entry.error = "未找到 winget,无法下载官网安装包";
|
|
920
|
-
} else if (selfUpdater && settings.useSelfUpdater !== false && !settings.dryRun) {
|
|
921
|
-
// 先让软件自带的更新服务试一次;没生效就退回官网全量安装包。
|
|
922
|
-
// Edge 的实测:EdgeUpdate 服务坏掉时 /ua 直接 0x80070002,而 winget 目录里的
|
|
923
|
-
// 官方安装包(企业版 MSI)在提权下能正常把 Edge 装到目标版本 —— winget 自己
|
|
924
|
-
// 拒绝执行只是因为「安装技术不同」这条策略。
|
|
925
|
-
entry.selfUpdater = selfUpdater;
|
|
926
|
-
const r = await runSelfUpdater(selfUpdater, log, signal, ctx);
|
|
927
|
-
entry.note = r.note || null;
|
|
928
|
-
if (r.ok) {
|
|
929
|
-
const check = await verifyInstalled({
|
|
930
|
-
wingetPath,
|
|
931
|
-
id,
|
|
932
|
-
before,
|
|
933
|
-
target,
|
|
934
|
-
signal,
|
|
935
|
-
log,
|
|
936
|
-
attempts: 12,
|
|
937
|
-
delayMs: 15000,
|
|
938
|
-
});
|
|
939
|
-
entry.verified = check.version || null;
|
|
940
|
-
if (check.changed || check.unverifiable) {
|
|
941
|
-
settled = true;
|
|
942
|
-
entry.ok = true;
|
|
943
|
-
entry.note = `${entry.note ? entry.note + ";" : ""}${check.changed ? "复核通过:" : "未复核:"}${check.reason}`;
|
|
944
|
-
log(check.changed ? "success" : "warn", `${id} ${check.changed ? "更新完成" : "已安装(未复核)"},${check.reason}`);
|
|
945
|
-
} else {
|
|
946
|
-
log("warn", `${id}:自带更新服务没有生效(${check.reason}),改用官网全量安装包`);
|
|
947
|
-
}
|
|
948
|
-
} else {
|
|
949
|
-
log("warn", `${id}:${r.error},改用官网全量安装包`);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
if (settled) {
|
|
954
|
-
// 自带更新服务已经完成并复核过
|
|
955
|
-
} else if (entry.error || !wingetPath || signal.aborted) {
|
|
956
|
-
log("error", `${id} 更新失败:${entry.error}`);
|
|
957
|
-
} else {
|
|
958
|
-
const dl = await downloadInstaller(wingetPath, id, dir, log, signal, { env, matchArch });
|
|
959
|
-
if (!dl.ok) {
|
|
960
|
-
entry.error = dl.error;
|
|
961
|
-
} else if (settings.dryRun) {
|
|
962
|
-
// 演练模式:照常从官网下载全量安装包并落盘,但不执行安装。
|
|
963
|
-
entry.installer = dl.installer;
|
|
964
|
-
entry.manifest = dl.manifest || null;
|
|
965
|
-
entry.ok = true;
|
|
966
|
-
entry.dryRun = true;
|
|
967
|
-
entry.note = "演练模式:已下载官网全量安装包,未执行安装";
|
|
968
|
-
log("warn", `${id}:演练模式,已下载 ${basename(dl.installer)},跳过安装`);
|
|
969
|
-
} else {
|
|
970
|
-
entry.installer = dl.installer;
|
|
971
|
-
entry.manifest = dl.manifest || null;
|
|
972
|
-
const inst = await runInstaller(dl.installer, dl.info, log, signal, ctx);
|
|
973
|
-
if (inst.ok) {
|
|
974
|
-
entry.ok = true;
|
|
975
|
-
entry.installOk = true;
|
|
976
|
-
entry.note = inst.note || null;
|
|
977
|
-
} else {
|
|
978
|
-
entry.error = inst.error;
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// 下载失败或安装失败 → winget upgrade 兜底(同样是官网全量包)
|
|
983
|
-
if (!entry.ok && !entry.dryRun && settings.hashFallbackUpgrade !== false && !signal.aborted) {
|
|
984
|
-
log("warn", `${id}:${entry.error},尝试 winget upgrade 兜底`);
|
|
985
|
-
const fb = await wingetUpgradeFallback(wingetPath, id, log, signal, { env, matchArch, run: ctx.runWinget });
|
|
986
|
-
if (fb.ok) {
|
|
987
|
-
entry.ok = true;
|
|
988
|
-
entry.installOk = true;
|
|
989
|
-
entry.error = null;
|
|
990
|
-
entry.note = fb.note || "已通过 winget 安装官网全量包";
|
|
991
|
-
} else {
|
|
992
|
-
entry.error = `${entry.error};兜底失败:${fb.error}`;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// 复核:退出码 0 ≠ 装上了。机器级安装包在没有管理员令牌时会静默退出 0 却什么都没装。
|
|
997
|
-
if (entry.ok && !entry.dryRun && !signal.aborted) {
|
|
998
|
-
const check = await verifyInstalled({
|
|
999
|
-
wingetPath,
|
|
1000
|
-
id,
|
|
1001
|
-
before,
|
|
1002
|
-
target,
|
|
1003
|
-
signal,
|
|
1004
|
-
log,
|
|
1005
|
-
attempts: 4,
|
|
1006
|
-
delayMs: 15000,
|
|
1007
|
-
});
|
|
1008
|
-
entry.verified = check.version || null;
|
|
1009
|
-
if (check.changed) {
|
|
1010
|
-
entry.note = `${entry.note ? entry.note + ";" : ""}复核通过:${check.reason}`;
|
|
1011
|
-
log("success", `${id} 更新完成,${check.reason}`);
|
|
1012
|
-
} else if (check.unverifiable) {
|
|
1013
|
-
// 读不到已装版本(卸载信息里没有版本号):按「已安装、未复核」处理,不能算失败
|
|
1014
|
-
entry.note = `${entry.note ? entry.note + ";" : ""}未复核:${check.reason}`;
|
|
1015
|
-
log("warn", `${id} 已安装,但无法复核版本:${check.reason}`);
|
|
1016
|
-
} else {
|
|
1017
|
-
entry.ok = false;
|
|
1018
|
-
const hint = updateHintFor(id);
|
|
1019
|
-
entry.error = `安装报告成功,但没有生效:${check.reason}${hint ? `。${hint}` : ""}`;
|
|
1020
|
-
log("error", `${id} 更新失败:${entry.error}`);
|
|
1021
|
-
}
|
|
1022
|
-
} else if (entry.ok && entry.dryRun) {
|
|
1023
|
-
// 演练模式没有真正安装,不做版本复核
|
|
1024
|
-
} else if (entry.ok) {
|
|
1025
|
-
log("success", `${id} 更新完成`);
|
|
1026
|
-
} else {
|
|
1027
|
-
log("error", `${id} 更新失败:${entry.error}`);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
} catch (err) {
|
|
1031
|
-
entry.ok = false;
|
|
1032
|
-
entry.error = String((err && err.message) || err);
|
|
1033
|
-
log("error", `${id} 更新异常:${entry.error}`);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
entry.durationMs = Date.now() - startedAt;
|
|
1037
|
-
if (!settings.keepInstallers && entry.installer) {
|
|
1038
|
-
rm(entry.installer, { force: true }).catch(() => {});
|
|
1039
|
-
// winget download 会同时落一份 .yaml 清单,别让它留在下载目录里
|
|
1040
|
-
if (entry.manifest) rm(entry.manifest, { force: true }).catch(() => {});
|
|
1041
|
-
}
|
|
1042
|
-
results.push(entry);
|
|
1043
|
-
onProgress({ running: true, total: queue.length, done: index, current: null, results });
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
const okCount = results.filter((r) => r.ok).length;
|
|
1047
|
-
onProgress({ running: false, total: queue.length, done: results.length, current: null, results, finishedAt: Date.now(), error: null });
|
|
1048
|
-
log(okCount === results.length ? "success" : "warn", `更新结束:成功 ${okCount} / ${results.length}`);
|
|
1049
|
-
return results;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/* ------------------------------------------------------------------ *
|
|
1053
|
-
* macOS:Homebrew cask 引擎
|
|
1054
|
-
*
|
|
1055
|
-
* Homebrew 自己会按当前 Mac 的架构取对应的 cask(Apple Silicon 走 arm64,
|
|
1056
|
-
* Intel 走 x86_64),所以这里不需要再拼下载地址;我们只负责把「已装版本 →
|
|
1057
|
-
* 可升级版本」读出来,并在用户确认后执行 upgrade / fetch。
|
|
1058
|
-
* ------------------------------------------------------------------ */
|
|
1059
|
-
|
|
1060
|
-
export function findBrew(env = process.env) {
|
|
1061
|
-
const candidates = [];
|
|
1062
|
-
if (env.HOMEBREW_PREFIX) candidates.push(join(env.HOMEBREW_PREFIX, "bin", "brew"));
|
|
1063
|
-
candidates.push("/opt/homebrew/bin/brew", "/usr/local/bin/brew", "/home/linuxbrew/.linuxbrew/bin/brew");
|
|
1064
|
-
const pathValue = env.PATH || env.Path || "";
|
|
1065
|
-
for (const entry of String(pathValue).split(":")) {
|
|
1066
|
-
const dir = entry.trim();
|
|
1067
|
-
if (dir.startsWith("/")) candidates.push(join(dir, "brew"));
|
|
1068
|
-
}
|
|
1069
|
-
for (const file of candidates) {
|
|
1070
|
-
try {
|
|
1071
|
-
if (lstatSync(file).isFile()) return file;
|
|
1072
|
-
} catch {
|
|
1073
|
-
/* 不可访问的候选路径 */
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
return null;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
async function runBrew(brewPath, args, opts = {}) {
|
|
1080
|
-
return runProcess(brewPath, args, {
|
|
1081
|
-
timeoutMs: opts.timeoutMs || 20 * 60 * 1000,
|
|
1082
|
-
signal: opts.signal,
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/** `brew list --cask --versions` → Map(token → version)。 */
|
|
1087
|
-
export function parseBrewCaskVersions(text) {
|
|
1088
|
-
const out = new Map();
|
|
1089
|
-
for (const line of String(text || "").split("\n")) {
|
|
1090
|
-
const parts = line.trim().split(/\s+/);
|
|
1091
|
-
if (parts.length < 2) continue;
|
|
1092
|
-
out.set(parts[0], parts.slice(1).join(" "));
|
|
1093
|
-
}
|
|
1094
|
-
return out;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/** `brew outdated --json=v2 --cask --greedy` → Map(token → 可用版本)。 */
|
|
1098
|
-
export function parseBrewOutdated(text) {
|
|
1099
|
-
const out = new Map();
|
|
1100
|
-
let json = null;
|
|
1101
|
-
try {
|
|
1102
|
-
json = JSON.parse(String(text || "").trim() || "{}");
|
|
1103
|
-
} catch {
|
|
1104
|
-
return out;
|
|
1105
|
-
}
|
|
1106
|
-
const casks = Array.isArray(json && json.casks) ? json.casks : [];
|
|
1107
|
-
for (const c of casks) {
|
|
1108
|
-
if (!c || !c.name) continue;
|
|
1109
|
-
const current = c.current_version || (Array.isArray(c.current_versions) ? c.current_versions[0] : "") || "";
|
|
1110
|
-
out.set(String(c.name), String(current));
|
|
1111
|
-
}
|
|
1112
|
-
return out;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
async function scanMacos({ settings, log, signal, env, startedAt, warnings }) {
|
|
1116
|
-
const brewPath = findBrew();
|
|
1117
|
-
|
|
1118
|
-
/* ---------------------------------------------------------------- *
|
|
1119
|
-
* 1. 先枚举「本机到底装了哪些应用」——这一步不依赖 Homebrew。
|
|
1120
|
-
* 旧实现只读 `brew list --cask`,没走 Homebrew 装的应用一个都看不到,
|
|
1121
|
-
* 于是正常 Mac 上就是「未检测到任何已安装应用」。
|
|
1122
|
-
* ---------------------------------------------------------------- */
|
|
1123
|
-
log("info", "正在枚举应用目录(/Applications 等)…");
|
|
1124
|
-
const apps = await scanAppBundles({ log });
|
|
1125
|
-
|
|
1126
|
-
/* ---------------------------------------------------------------- *
|
|
1127
|
-
* 2. Homebrew:哪些 cask 已装、哪些有新版(没装 Homebrew 也继续)。
|
|
1128
|
-
* ---------------------------------------------------------------- */
|
|
1129
|
-
let brewVersion = "";
|
|
1130
|
-
const brews = new Map();
|
|
1131
|
-
const outdated = new Map();
|
|
1132
|
-
|
|
1133
|
-
if (brewPath) {
|
|
1134
|
-
const verRes = await runBrew(brewPath, ["--version"], { timeoutMs:
|
|
1135
|
-
brewVersion = firstLine(outputText(verRes)).replace(/^Homebrew\s+/i, "");
|
|
1136
|
-
log("info", `Homebrew ${brewVersion}(${brewPath})· ${env.archLabel}`);
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
const
|
|
1191
|
-
const
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 更新引擎:扫描 → 排队 → 逐个「下载官网全量安装包 → 校验 → 静默安装 → 复核版本」。
|
|
3
|
+
*
|
|
4
|
+
* 设计取舍:
|
|
5
|
+
* - 检测只用 winget 的公开子命令(list / upgrade),不需要启动任何被检测的软件;
|
|
6
|
+
* - 更新优先走 winget download:它把**官网原始安装包**完整下载到本地(不是增量补丁),
|
|
7
|
+
* 我们再自己用清单里的静默参数执行,做到「全量包 + 可留档 + 可回退」;
|
|
8
|
+
* - download 失败时退回 winget upgrade(同样是官网全量包,只是安装过程交给 winget);
|
|
9
|
+
* - 每步都写日志,失败原因原样透出,不吞错误。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdir, readdir, stat, readFile, rm } from "node:fs/promises";
|
|
13
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
14
|
+
import { join, basename, isAbsolute } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import { runProcess, outputText } from "./proc.js";
|
|
18
|
+
import { elevationAvailable, isElevated, runElevated } from "./elevate.js";
|
|
19
|
+
import {
|
|
20
|
+
findWinget,
|
|
21
|
+
runWinget,
|
|
22
|
+
parseInstalledList,
|
|
23
|
+
parseUpgradeList,
|
|
24
|
+
parseWingetVersion,
|
|
25
|
+
isWingetUnavailable,
|
|
26
|
+
} from "./winget.js";
|
|
27
|
+
import { readRegistryApps, normalizeName } from "./registry.js";
|
|
28
|
+
import { compareVersions, isNewer, looksLikeVersion } from "./version.js";
|
|
29
|
+
import { detectEnvironment, describeEnvironment, engineFor, ENGINE_BREW, ENGINE_WINGET } from "./platform.js";
|
|
30
|
+
import { scanAppBundles, fetchCaskCatalog, parseCaskCatalog, buildMacosItems } from "./macos.js";
|
|
31
|
+
|
|
32
|
+
const INSTALLER_EXT = /\.(exe|msi|msix|appx|msixbundle|pkg|dmg)$/i;
|
|
33
|
+
|
|
34
|
+
/** 当前环境(进程级缓存:同一次运行里平台/架构不会变)。 */
|
|
35
|
+
let cachedEnv = null;
|
|
36
|
+
export function currentEnvironment() {
|
|
37
|
+
if (!cachedEnv) cachedEnv = detectEnvironment();
|
|
38
|
+
return cachedEnv;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ *
|
|
42
|
+
* 清单解析(只取我们需要的字段,不引第三方 YAML 库)
|
|
43
|
+
* ------------------------------------------------------------------ */
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从 winget download 落盘的 merged manifest 里提取安装信息。
|
|
47
|
+
* 只做「按缩进扫描」的定向提取:InstallerType / Silent 参数 / Scope / InstallerUrl。
|
|
48
|
+
*/
|
|
49
|
+
export function parseInstallerManifest(yamlText) {
|
|
50
|
+
const text = String(yamlText || "").replace(/\r\n?/g, "\n");
|
|
51
|
+
const info = {
|
|
52
|
+
installerType: "",
|
|
53
|
+
installerUrl: "",
|
|
54
|
+
silent: "",
|
|
55
|
+
silentWithProgress: "",
|
|
56
|
+
scope: "",
|
|
57
|
+
elevation: "",
|
|
58
|
+
productCode: "",
|
|
59
|
+
};
|
|
60
|
+
const lines = text.split("\n");
|
|
61
|
+
let inInstallers = false;
|
|
62
|
+
let inSwitches = false;
|
|
63
|
+
let switchIndent = -1;
|
|
64
|
+
for (const rawLine of lines) {
|
|
65
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
66
|
+
if (!line.trim()) continue;
|
|
67
|
+
const indent = line.length - line.trimStart().length;
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (/^Installers:\s*$/.test(trimmed)) {
|
|
70
|
+
inInstallers = true;
|
|
71
|
+
inSwitches = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (inInstallers && indent === 0 && /^[A-Za-z]/.test(trimmed)) {
|
|
75
|
+
// 回到顶层键:Installers 段结束
|
|
76
|
+
inInstallers = false;
|
|
77
|
+
inSwitches = false;
|
|
78
|
+
}
|
|
79
|
+
if (!inInstallers) continue;
|
|
80
|
+
|
|
81
|
+
if (/^InstallerSwitches:\s*$/.test(trimmed)) {
|
|
82
|
+
inSwitches = true;
|
|
83
|
+
switchIndent = indent;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inSwitches && indent <= switchIndent && !/^InstallerSwitches:/.test(trimmed)) {
|
|
87
|
+
inSwitches = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (inSwitches) {
|
|
91
|
+
const m = trimmed.match(/^(SilentWithProgress|Silent|Log|InstallLocation):\s*(.+)$/);
|
|
92
|
+
if (m) {
|
|
93
|
+
const value = stripQuotes(m[2]);
|
|
94
|
+
if (m[1] === "Silent") info.silent = value;
|
|
95
|
+
else if (m[1] === "SilentWithProgress") info.silentWithProgress = value;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const kv = trimmed.match(/^(InstallerType|InstallerUrl|Scope|ElevationRequirement|ProductCode|Architecture):\s*(.+)$/);
|
|
101
|
+
if (kv) {
|
|
102
|
+
const value = stripQuotes(kv[2]);
|
|
103
|
+
switch (kv[1]) {
|
|
104
|
+
case "InstallerType":
|
|
105
|
+
if (!info.installerType) info.installerType = value;
|
|
106
|
+
break;
|
|
107
|
+
case "InstallerUrl":
|
|
108
|
+
if (!info.installerUrl) info.installerUrl = value;
|
|
109
|
+
break;
|
|
110
|
+
case "Scope":
|
|
111
|
+
if (!info.scope) info.scope = value;
|
|
112
|
+
break;
|
|
113
|
+
case "ElevationRequirement":
|
|
114
|
+
if (!info.elevation) info.elevation = value;
|
|
115
|
+
break;
|
|
116
|
+
case "ProductCode":
|
|
117
|
+
if (!info.productCode) info.productCode = value;
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return info;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stripQuotes(value) {
|
|
128
|
+
let v = String(value || "").trim();
|
|
129
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
|
|
130
|
+
v = v.slice(1, -1);
|
|
131
|
+
}
|
|
132
|
+
return v;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 根据安装包类型给出静默安装参数(清单没写时的兜底)。 */
|
|
136
|
+
export function defaultSilentArgs(installerType, filePath) {
|
|
137
|
+
const type = String(installerType || "").toLowerCase();
|
|
138
|
+
const ext = String(filePath || "").toLowerCase();
|
|
139
|
+
if (type === "msi" || ext.endsWith(".msi")) return ["/qn", "/norestart"];
|
|
140
|
+
if (type === "inno" || type === "innosetup") return ["/SP-", "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"];
|
|
141
|
+
if (type === "nullsoft" || type === "nsis") return ["/S"];
|
|
142
|
+
if (type === "burn" || type === "wix") return ["/quiet", "/norestart"];
|
|
143
|
+
if (type === "msix" || type === "appx") return [];
|
|
144
|
+
// 未知 exe:优先 Inno 参数(国内/开源软件最常见),失败时由上层改用 winget upgrade
|
|
145
|
+
return ["/S"];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 把清单里的 Silent 字符串切成参数数组(保留引号内的整体)。 */
|
|
149
|
+
export function splitSwitchArgs(silent) {
|
|
150
|
+
const text = String(silent || "").trim();
|
|
151
|
+
if (!text) return [];
|
|
152
|
+
const out = [];
|
|
153
|
+
let current = "";
|
|
154
|
+
let quote = null;
|
|
155
|
+
for (const ch of text) {
|
|
156
|
+
if (quote) {
|
|
157
|
+
if (ch === quote) quote = null;
|
|
158
|
+
else current += ch;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (ch === '"' || ch === "'") {
|
|
162
|
+
quote = ch;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ch === " ") {
|
|
166
|
+
if (current) {
|
|
167
|
+
out.push(current);
|
|
168
|
+
current = "";
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
current += ch;
|
|
173
|
+
}
|
|
174
|
+
if (current) out.push(current);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 决定用什么参数跑安装包。
|
|
180
|
+
*
|
|
181
|
+
* 顺序很重要:清单里声明的 Silent 参数优先(那是发布方自己给的、与安装包
|
|
182
|
+
* 完全匹配的一组开关);没有清单时才按安装包类型猜。
|
|
183
|
+
*
|
|
184
|
+
* 注意:清单里的 `/LOG="<LOGPATH>"` 是占位符模板,不能原样传,所以这里只
|
|
185
|
+
* 取 Silent / SilentWithProgress 两项,占位符再单独剔除。
|
|
186
|
+
*/
|
|
187
|
+
export function resolveInstallArgs(info, installerPath) {
|
|
188
|
+
const declared = (info && (info.silent || info.silentWithProgress)) || "";
|
|
189
|
+
// 占位符模板(<LOGPATH> / <INSTALLPATH>)不能原样传给安装包:删掉占位符,
|
|
190
|
+
// 再把因此变空值的开关(形如 /LOG=)整条剔除。
|
|
191
|
+
const cleaned = String(declared).replace(/<[A-Za-z_]+>/g, "").replace(/\s+/g, " ").trim();
|
|
192
|
+
const declaredArgs = splitSwitchArgs(cleaned).filter((a) => {
|
|
193
|
+
if (!a) return false;
|
|
194
|
+
if (/^\/?[A-Za-z]+=$/.test(a)) return false; // /LOG= 这类空值开关
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
if (declaredArgs.length) return declaredArgs;
|
|
198
|
+
return defaultSilentArgs(info && info.installerType, installerPath);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ------------------------------------------------------------------ *
|
|
202
|
+
* 扫描
|
|
203
|
+
* ------------------------------------------------------------------ */
|
|
204
|
+
|
|
205
|
+
export function resolveDownloadDir(settings) {
|
|
206
|
+
const custom = String((settings && settings.downloadDir) || "").trim();
|
|
207
|
+
if (custom && isAbsolute(custom)) return custom;
|
|
208
|
+
return join(homedir(), "Downloads", "DSH App Updater");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isStoreId(id) {
|
|
212
|
+
return /^(MSIX|MSSTORE)\\/i.test(String(id || ""));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 执行一次完整扫描。
|
|
217
|
+
* @param {{ settings: object, log: (level: string, text: string) => void, signal?: AbortSignal }} ctx
|
|
218
|
+
*/
|
|
219
|
+
export async function scanApps({ settings, log, signal }) {
|
|
220
|
+
const startedAt = Date.now();
|
|
221
|
+
const warnings = [];
|
|
222
|
+
const env = currentEnvironment();
|
|
223
|
+
|
|
224
|
+
log("info", `运行环境:${describeEnvironment(env)}(引擎:${engineFor(env.family)})`);
|
|
225
|
+
|
|
226
|
+
if (env.family === "macos") {
|
|
227
|
+
return scanMacos({ settings, log, signal, env, startedAt, warnings });
|
|
228
|
+
}
|
|
229
|
+
if (env.family !== "windows") {
|
|
230
|
+
log("warn", `当前系统 ${env.platform} 没有可用的更新引擎,本插件支持 Windows 与 macOS`);
|
|
231
|
+
return {
|
|
232
|
+
env,
|
|
233
|
+
engine: { id: engineFor(env.family), available: false, version: "", path: "" },
|
|
234
|
+
scannedAt: Date.now(),
|
|
235
|
+
startedAt,
|
|
236
|
+
stats: { total: 0, upgradable: 0, unsupported: 0 },
|
|
237
|
+
items: [],
|
|
238
|
+
warnings: [`当前系统 ${env.platform} 暂不支持(本插件面向 Windows 与 macOS)`],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const wingetPath = findWinget();
|
|
243
|
+
|
|
244
|
+
/** @type {Map<string, any>} */
|
|
245
|
+
const byId = new Map();
|
|
246
|
+
/** @type {Map<string, string>} id → available */
|
|
247
|
+
const availableById = new Map();
|
|
248
|
+
|
|
249
|
+
let wingetInfo = { available: false, version: "", path: wingetPath || "" };
|
|
250
|
+
if (wingetPath) {
|
|
251
|
+
const ver = await runWinget(wingetPath, ["--version"], { timeoutMs: 20000 });
|
|
252
|
+
const version = parseWingetVersion(ver.stdout);
|
|
253
|
+
wingetInfo = { available: ver.ok && !!version, version, path: wingetPath };
|
|
254
|
+
if (!wingetInfo.available) warnings.push("winget 无法执行,仅使用注册表检测(无法判断是否有新版本)");
|
|
255
|
+
} else {
|
|
256
|
+
warnings.push("未找到 winget.exe,仅使用注册表检测(无法判断是否有新版本)");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (wingetInfo.available) {
|
|
260
|
+
log("info", "正在读取 winget 已安装清单…");
|
|
261
|
+
const listRes = await runWinget(
|
|
262
|
+
wingetPath,
|
|
263
|
+
["list", "--accept-source-agreements", "--disable-interactivity"],
|
|
264
|
+
{ timeoutMs: 240000, signal }
|
|
265
|
+
);
|
|
266
|
+
if (!listRes.ok && isWingetUnavailable(listRes)) {
|
|
267
|
+
warnings.push(`winget list 失败:${firstLine(outputText(listRes)) || "未知错误"}`);
|
|
268
|
+
wingetInfo.available = false;
|
|
269
|
+
} else {
|
|
270
|
+
const installed = parseInstalledList(listRes.stdout);
|
|
271
|
+
log("info", `winget 报告 ${installed.length} 个已安装条目`);
|
|
272
|
+
for (const item of installed) {
|
|
273
|
+
if (!item.id) continue;
|
|
274
|
+
byId.set(item.id, {
|
|
275
|
+
id: item.id,
|
|
276
|
+
name: item.name || item.id,
|
|
277
|
+
version: item.version || "",
|
|
278
|
+
available: "",
|
|
279
|
+
publisher: "",
|
|
280
|
+
source: /^(MSIX|MSSTORE)\\/i.test(item.id) ? "msstore" : "winget",
|
|
281
|
+
installLocation: "",
|
|
282
|
+
hasUpdate: false,
|
|
283
|
+
updateSupported: false,
|
|
284
|
+
reason: null,
|
|
285
|
+
kind: null,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
log("info", "正在向软件目录查询可用更新…");
|
|
290
|
+
const upRes = await runWinget(
|
|
291
|
+
wingetPath,
|
|
292
|
+
["upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity"],
|
|
293
|
+
{ timeoutMs: 240000, signal }
|
|
294
|
+
);
|
|
295
|
+
if (upRes.ok || upRes.stdout) {
|
|
296
|
+
const upgradable = parseUpgradeList(upRes.stdout);
|
|
297
|
+
log("info", `软件目录报告 ${upgradable.length} 个可更新条目`);
|
|
298
|
+
for (const item of upgradable) {
|
|
299
|
+
if (!item.id) continue;
|
|
300
|
+
if (item.available) availableById.set(item.id, item.available);
|
|
301
|
+
const existing = byId.get(item.id);
|
|
302
|
+
if (existing) {
|
|
303
|
+
existing.available = item.available || "";
|
|
304
|
+
if (!existing.version && item.version) existing.version = item.version;
|
|
305
|
+
} else {
|
|
306
|
+
byId.set(item.id, {
|
|
307
|
+
id: item.id,
|
|
308
|
+
name: item.name || item.id,
|
|
309
|
+
version: item.version || "",
|
|
310
|
+
available: item.available || "",
|
|
311
|
+
publisher: "",
|
|
312
|
+
source: "winget",
|
|
313
|
+
installLocation: "",
|
|
314
|
+
hasUpdate: false,
|
|
315
|
+
updateSupported: false,
|
|
316
|
+
reason: null,
|
|
317
|
+
kind: null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
warnings.push("查询可用更新失败,本次只能显示已安装清单");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 注册表兜底:补齐 winget 目录未收录的应用,并给 winget 条目补上发布者/安装位置
|
|
328
|
+
if (settings.includeRegistryOnly) {
|
|
329
|
+
log("info", "正在读取注册表已安装应用…");
|
|
330
|
+
const reg = await readRegistryApps();
|
|
331
|
+
if (!reg.ok) {
|
|
332
|
+
warnings.push(`注册表读取失败:${reg.error || "未知错误"}`);
|
|
333
|
+
} else {
|
|
334
|
+
log("info", `注册表报告 ${reg.apps.length} 个应用`);
|
|
335
|
+
const nameIndex = new Map();
|
|
336
|
+
for (const app of byId.values()) nameIndex.set(normalizeName(app.name), app);
|
|
337
|
+
for (const r of reg.apps) {
|
|
338
|
+
const key = normalizeName(r.name);
|
|
339
|
+
const matched = key ? nameIndex.get(key) : null;
|
|
340
|
+
if (matched) {
|
|
341
|
+
if (!matched.publisher && r.publisher) matched.publisher = r.publisher;
|
|
342
|
+
if (!matched.installLocation && r.location) matched.installLocation = r.location;
|
|
343
|
+
if (!matched.version && r.version) matched.version = r.version;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const id = `reg:${r.key || key || r.name}`;
|
|
347
|
+
if (byId.has(id)) continue;
|
|
348
|
+
byId.set(id, {
|
|
349
|
+
id,
|
|
350
|
+
name: r.name,
|
|
351
|
+
version: r.version || "",
|
|
352
|
+
available: "",
|
|
353
|
+
publisher: r.publisher || "",
|
|
354
|
+
source: "registry",
|
|
355
|
+
installLocation: r.location || "",
|
|
356
|
+
hasUpdate: false,
|
|
357
|
+
updateSupported: false,
|
|
358
|
+
reason: null,
|
|
359
|
+
kind: null,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 汇总判定
|
|
366
|
+
const items = [];
|
|
367
|
+
for (const app of byId.values()) {
|
|
368
|
+
if (!settings.includeStoreApps && app.source === "msstore") continue;
|
|
369
|
+
if (!settings.includeRegistryOnly && app.source === "registry") continue;
|
|
370
|
+
const available = app.available || availableById.get(app.id) || "";
|
|
371
|
+
let hasUpdate = false;
|
|
372
|
+
let reason = null;
|
|
373
|
+
let updateSupported = false;
|
|
374
|
+
|
|
375
|
+
if (isStoreId(app.id) || app.source === "msstore") {
|
|
376
|
+
reason = "store-managed";
|
|
377
|
+
} else if (!available) {
|
|
378
|
+
reason = app.source === "registry" ? "not-in-catalog" : null;
|
|
379
|
+
} else if (!looksLikeVersion(app.version)) {
|
|
380
|
+
// winget 显示 Unknown:无法确认是否真的更新,按可更新处理但不静默判定
|
|
381
|
+
hasUpdate = true;
|
|
382
|
+
updateSupported = true;
|
|
383
|
+
} else if (isNewer(available, app.version)) {
|
|
384
|
+
hasUpdate = true;
|
|
385
|
+
updateSupported = true;
|
|
386
|
+
} else {
|
|
387
|
+
hasUpdate = false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (hasUpdate && !updateSupported && !reason) reason = "unknown-version";
|
|
391
|
+
items.push({
|
|
392
|
+
...app,
|
|
393
|
+
available: hasUpdate ? available : "",
|
|
394
|
+
hasUpdate,
|
|
395
|
+
updateSupported,
|
|
396
|
+
reason: hasUpdate && updateSupported ? null : reason,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
items.sort((a, b) => {
|
|
401
|
+
if (a.hasUpdate !== b.hasUpdate) return a.hasUpdate ? -1 : 1;
|
|
402
|
+
if (a.updateSupported !== b.updateSupported) return a.updateSupported ? -1 : 1;
|
|
403
|
+
return String(a.name).localeCompare(String(b.name), "zh-Hans-CN");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const stats = {
|
|
407
|
+
total: items.length,
|
|
408
|
+
upgradable: items.filter((i) => i.hasUpdate && i.updateSupported).length,
|
|
409
|
+
unsupported: items.filter((i) => i.hasUpdate && !i.updateSupported).length,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
log("success", `扫描完成:${stats.total} 个应用,${stats.upgradable} 个可更新`);
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
env,
|
|
416
|
+
engine: {
|
|
417
|
+
id: ENGINE_WINGET,
|
|
418
|
+
available: Boolean(wingetInfo.available),
|
|
419
|
+
version: wingetInfo.version || "",
|
|
420
|
+
path: wingetInfo.path || "",
|
|
421
|
+
},
|
|
422
|
+
winget: wingetInfo,
|
|
423
|
+
scannedAt: Date.now(),
|
|
424
|
+
startedAt,
|
|
425
|
+
stats,
|
|
426
|
+
items,
|
|
427
|
+
warnings,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* ------------------------------------------------------------------ *
|
|
432
|
+
* 提权、复核与「自带更新服务」
|
|
433
|
+
* ------------------------------------------------------------------ */
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* winget 报「清单里没有可用安装包」一类错误(本机架构没有对应安装包)。
|
|
437
|
+
* 中文输出是「找不到适用的安装程序」,英文是 “No applicable installer found”。
|
|
438
|
+
*
|
|
439
|
+
* 这类失败必须退回「不限架构」再来一次:虎牙直播(Huya.Huya)的清单里只有
|
|
440
|
+
* 一个不标架构的通用 exe,带上 `--architecture x64` 就永远找不到。
|
|
441
|
+
*/
|
|
442
|
+
export function isArchNotFoundError(text) {
|
|
443
|
+
const raw = String(text || "");
|
|
444
|
+
if (!raw) return false;
|
|
445
|
+
return /architecture|架构|applicable installer|not applicable|找不到适用|没有适用/i.test(raw);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 安装器退出码 → 人话。输出为空时兜底;MSI 的真实原因在它自己的日志里。 */
|
|
449
|
+
export function explainInstallerExitCode(code, text) {
|
|
450
|
+
const first = firstLine(text);
|
|
451
|
+
if (first) return first;
|
|
452
|
+
const map = {
|
|
453
|
+
1603: "安装程序返回 1603(安装失败:常见原因是权限不足、文件被占用或已装其它版本;MSI 详情见日志)",
|
|
454
|
+
1619: "安装程序返回 1619(安装包无法打开)",
|
|
455
|
+
1620: "安装程序返回 1620(安装包损坏或不可读)",
|
|
456
|
+
1625: "安装程序返回 1625(被系统策略禁止)",
|
|
457
|
+
1638: "安装程序返回 1638(已安装其它版本)",
|
|
458
|
+
1730: "安装程序返回 1730(卸载/安装需要管理员权限)",
|
|
459
|
+
};
|
|
460
|
+
return map[code] || `安装程序退出码 ${code}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 判断一次更新是否真的生效。
|
|
465
|
+
*
|
|
466
|
+
* 只看安装器退出码是不够的:机器级安装包在没有管理员令牌时会**静默退出 0**
|
|
467
|
+
* 却什么都没装(实测 Logitech G HUB 2026.6.981214 → 目标 2026.7.992920,
|
|
468
|
+
* 退出码 0,Program Files 里的文件与 winget 报的版本都没有变化)。
|
|
469
|
+
* 所以拿「安装前版本 / 安装后版本 / 目标版本」三个值判断,判不出来才算失败。
|
|
470
|
+
*
|
|
471
|
+
* @returns {{changed: boolean, reason: string}}
|
|
472
|
+
*/
|
|
473
|
+
export function evaluateUpdateResult({ before, after, target }) {
|
|
474
|
+
const a = String(after == null ? "" : after).trim();
|
|
475
|
+
const b = String(before == null ? "" : before).trim();
|
|
476
|
+
const t = String(target == null ? "" : target).trim();
|
|
477
|
+
// winget 读不到已装版本(虎牙直播的卸载信息里就没有版本号)→ 无法复核:
|
|
478
|
+
// 既不能判生效也不能判失败,交给调用方按「已安装、未复核」处理。
|
|
479
|
+
if (!looksLikeVersion(a)) {
|
|
480
|
+
return {
|
|
481
|
+
changed: false,
|
|
482
|
+
unverifiable: true,
|
|
483
|
+
reason: a ? `winget 读不到已装版本(显示为 ${a})` : "安装后读不到版本",
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
// 达到或超过目标版本 → 生效
|
|
487
|
+
if (t && !isNewer(t, a)) {
|
|
488
|
+
return { changed: true, reason: `版本已是 ${a}(目标 ${t})` };
|
|
489
|
+
}
|
|
490
|
+
// 目标版本未知时,只能看它是否真的变新了
|
|
491
|
+
if (b && looksLikeVersion(b) && isNewer(a, b)) {
|
|
492
|
+
return { changed: true, reason: `版本从 ${b} 升到 ${a}` };
|
|
493
|
+
}
|
|
494
|
+
if (t) return { changed: false, reason: `版本没有变化(仍为 ${a},目标 ${t})` };
|
|
495
|
+
return { changed: false, reason: `版本没有变化(仍为 ${a})` };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** 可取消的等待。 */
|
|
499
|
+
function delay(ms, signal) {
|
|
500
|
+
return new Promise((resolve) => {
|
|
501
|
+
if (signal && signal.aborted) {
|
|
502
|
+
resolve();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const finish = () => {
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
if (signal) signal.removeEventListener("abort", finish);
|
|
508
|
+
resolve();
|
|
509
|
+
};
|
|
510
|
+
const timer = setTimeout(finish, ms);
|
|
511
|
+
if (signal) signal.addEventListener("abort", finish, { once: true });
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** 提权跑 winget:winget 自己的安装动作同样需要管理员令牌才能装机器级包。 */
|
|
516
|
+
async function runWingetElevated(wingetPath, args, opts = {}) {
|
|
517
|
+
const res = await runElevated(wingetPath, args, { timeoutMs: opts.timeoutMs, signal: opts.signal });
|
|
518
|
+
return {
|
|
519
|
+
ok: res.ok,
|
|
520
|
+
code: res.code,
|
|
521
|
+
stdout: res.stdout || "",
|
|
522
|
+
stderr: "",
|
|
523
|
+
error: res.spawnError ? "spawn" : res.timedOut ? "timeout" : null,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 本轮的「目标版本」表:复核「到底升没升上去」的依据。
|
|
529
|
+
* 用一次 `winget upgrade --include-unknown` 拿全量(含已装版本为 Unknown 的应用)。
|
|
530
|
+
*/
|
|
531
|
+
async function fetchUpgradeTargets(wingetPath, ctx, signal) {
|
|
532
|
+
const map = new Map();
|
|
533
|
+
if (!wingetPath) return map;
|
|
534
|
+
try {
|
|
535
|
+
const res = await ctx.runWinget(
|
|
536
|
+
wingetPath,
|
|
537
|
+
["upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity"],
|
|
538
|
+
{ timeoutMs: 240000, signal }
|
|
539
|
+
);
|
|
540
|
+
if (res.ok) {
|
|
541
|
+
for (const row of parseUpgradeList(res.stdout)) {
|
|
542
|
+
const key = String(row.id || "").toLowerCase();
|
|
543
|
+
if (key && row.available) map.set(key, row.available);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch {
|
|
547
|
+
// 拿不到目标版本时退化为「只看版本有没有变化」
|
|
548
|
+
}
|
|
549
|
+
return map;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 自带更新服务的软件:winget 目录里给的是**企业版**安装包,与本机的消费版
|
|
554
|
+
* 安装技术不匹配,winget 会直接拒绝(0x8A15008E「安装技术与当前安装的版本
|
|
555
|
+
* 不同,请卸载包并安装较新版本」)。这类软件交给它自己的更新服务。
|
|
556
|
+
*/
|
|
557
|
+
const SELF_UPDATERS = new Map([
|
|
558
|
+
["microsoft.edge", "edgeupdate"],
|
|
559
|
+
["microsoft.edgewebview2runtime", "edgeupdate"],
|
|
560
|
+
["microsoft.edgewebview", "edgeupdate"],
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 少数软件的更新不由安装包完成,而是由它自己的常驻更新器在**应用启动时**应用
|
|
565
|
+
* (实测 Logitech G HUB:更新包早就下载好躺在 %ProgramData%\LGHUB\next.json 指向的
|
|
566
|
+
* depots 里,安装器 `--silent` 和 winget upgrade 都退出 0 什么都不做,只有启动
|
|
567
|
+
* G HUB 本体后 current.json 才跳到新版本)。
|
|
568
|
+
*
|
|
569
|
+
* 插件承诺「不需要打开被检测的软件」,所以这里只给提示,不替用户启动 app。
|
|
570
|
+
*/
|
|
571
|
+
const UPDATE_HINTS = new Map([
|
|
572
|
+
[
|
|
573
|
+
"logitech.ghub",
|
|
574
|
+
"G HUB 的更新由它自己的更新器在「启动 G HUB」时应用:安装包与 winget 都可能退出 0 而不生效,手动打开一次 G HUB 即可完成升级(插件不会替你启动软件)",
|
|
575
|
+
],
|
|
576
|
+
[
|
|
577
|
+
"microsoft.edge",
|
|
578
|
+
"Edge 由 EdgeUpdate 服务维护;该服务不可用时插件会退回安装官网全量包(企业版 MSI,提权执行)",
|
|
579
|
+
],
|
|
580
|
+
]);
|
|
581
|
+
|
|
582
|
+
/** 这条应用有没有「安装之外的更新方式」提示。 */
|
|
583
|
+
export function updateHintFor(id) {
|
|
584
|
+
return UPDATE_HINTS.get(String(id || "").toLowerCase()) || "";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** 定位 Microsoft Edge 自带的更新服务。 */
|
|
588
|
+
export function findEdgeUpdate(env = process.env) {
|
|
589
|
+
const roots = [
|
|
590
|
+
env["ProgramFiles(x86)"],
|
|
591
|
+
env.ProgramFiles,
|
|
592
|
+
"C:\\Program Files (x86)",
|
|
593
|
+
"C:\\Program Files",
|
|
594
|
+
].filter(Boolean);
|
|
595
|
+
for (const root of roots) {
|
|
596
|
+
const file = join(root, "Microsoft", "EdgeUpdate", "MicrosoftEdgeUpdate.exe");
|
|
597
|
+
try {
|
|
598
|
+
if (existsSync(file)) return file;
|
|
599
|
+
} catch {
|
|
600
|
+
// 受保护目录读不到时继续找下一个
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 触发自带更新服务(目前只有 Edge)。
|
|
608
|
+
* 它的退出码不代表版本已经就位(只是把任务交给服务),所以交给 verifyInstalled 轮询确认。
|
|
609
|
+
*/
|
|
610
|
+
async function runSelfUpdater(kind, log, signal, ctx) {
|
|
611
|
+
if (kind !== "edgeupdate") return { ok: false, error: "未知的自带更新方式" };
|
|
612
|
+
const exe = findEdgeUpdate();
|
|
613
|
+
if (!exe) return { ok: false, error: "未找到 MicrosoftEdgeUpdate.exe(Edge 自带更新服务)" };
|
|
614
|
+
log("info", "改用 Edge 自带更新服务(winget 目录里是企业版 MSI,与本机消费版 Edge 的安装技术不匹配)");
|
|
615
|
+
const res = await ctx.run(exe, ["/ua"], { timeoutMs: 30 * 60 * 1000, signal });
|
|
616
|
+
if (res.spawnError) return { ok: false, error: res.spawnError };
|
|
617
|
+
if (res.timedOut) return { ok: false, error: "Edge 自带更新服务超时" };
|
|
618
|
+
return { ok: true, note: "已交给 Microsoft Edge 更新服务,等待版本就位" };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 安装后复核:读已装版本,与目标/安装前版本比对,必要时轮询等待
|
|
623
|
+
* (Edge 这类自带更新服务不是同步生效的)。
|
|
624
|
+
*/
|
|
625
|
+
async function verifyInstalled({ wingetPath, id, before, target, signal, log, attempts = 1, delayMs = 15000 }) {
|
|
626
|
+
let version = "";
|
|
627
|
+
let verdict = { changed: false, reason: "尚未复核" };
|
|
628
|
+
const rounds = Math.max(1, attempts);
|
|
629
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
630
|
+
if (signal && signal.aborted) break;
|
|
631
|
+
version = await readInstalledVersion(wingetPath, id, signal);
|
|
632
|
+
verdict = evaluateUpdateResult({ before, after: version, target });
|
|
633
|
+
if (verdict.changed || verdict.unverifiable) return { ...verdict, version };
|
|
634
|
+
if (i + 1 < rounds) {
|
|
635
|
+
if (log && i === 0) {
|
|
636
|
+
log("info", `${id}:暂未读到新版本(${version || "未知"}),继续等待…`);
|
|
637
|
+
}
|
|
638
|
+
await delay(delayMs, signal);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return { ...verdict, version };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function firstLine(text) {
|
|
645
|
+
const line = String(text || "").trim().split("\n")[0] || "";
|
|
646
|
+
return line.slice(0, 200);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* ------------------------------------------------------------------ *
|
|
650
|
+
* 更新
|
|
651
|
+
* ------------------------------------------------------------------ */
|
|
652
|
+
|
|
653
|
+
/** 目录快照,用于在 winget download 后找出新增文件。 */
|
|
654
|
+
async function snapshotDir(dir) {
|
|
655
|
+
const map = new Map();
|
|
656
|
+
try {
|
|
657
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
658
|
+
for (const e of entries) {
|
|
659
|
+
if (!e.isFile()) continue;
|
|
660
|
+
try {
|
|
661
|
+
const st = await stat(join(dir, e.name));
|
|
662
|
+
map.set(e.name, st.mtimeMs);
|
|
663
|
+
} catch {
|
|
664
|
+
/* 文件在扫描间隙被删掉 */
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
/* 目录不存在 */
|
|
669
|
+
}
|
|
670
|
+
return map;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 下载官网全量安装包。
|
|
675
|
+
* @returns {Promise<{ok:boolean, installer?:string, manifest?:string, info?:object, error?:string}>}
|
|
676
|
+
*/
|
|
677
|
+
async function downloadInstaller(wingetPath, id, dir, log, signal, opts = {}) {
|
|
678
|
+
await mkdir(dir, { recursive: true });
|
|
679
|
+
const before = await snapshotDir(dir);
|
|
680
|
+
const env = opts.env || currentEnvironment();
|
|
681
|
+
const archFlag = opts.matchArch === false ? "" : env.wingetArch || "";
|
|
682
|
+
if (archFlag) log("info", `按本机架构选择安装包:${env.archLabel}(--architecture ${archFlag})`);
|
|
683
|
+
else log("info", `未指定架构,交给 winget 自动匹配(${env.archLabel})`);
|
|
684
|
+
log("info", `下载官网全量安装包:${id}`);
|
|
685
|
+
const args = [
|
|
686
|
+
"download",
|
|
687
|
+
"--id",
|
|
688
|
+
id,
|
|
689
|
+
"--download-directory",
|
|
690
|
+
dir,
|
|
691
|
+
"--accept-package-agreements",
|
|
692
|
+
"--accept-source-agreements",
|
|
693
|
+
"--disable-interactivity",
|
|
694
|
+
];
|
|
695
|
+
if (archFlag) args.push("--architecture", archFlag);
|
|
696
|
+
const res = await runWinget(wingetPath, args, { timeoutMs: 30 * 60 * 1000, signal });
|
|
697
|
+
const text = outputText(res);
|
|
698
|
+
if (!res.ok) {
|
|
699
|
+
const err = classifyWingetError(text) || `winget download 退出码 ${res.code}`;
|
|
700
|
+
// 指定架构失败(清单里没有这一架构的安装包)时,退回让 winget 自己挑,并如实记录
|
|
701
|
+
if (archFlag && isArchNotFoundError(text)) {
|
|
702
|
+
log("warn", `${id}:清单里没有 ${archFlag} 架构的安装包,改为自动匹配`);
|
|
703
|
+
return downloadInstaller(wingetPath, id, dir, log, signal, { ...opts, matchArch: false });
|
|
704
|
+
}
|
|
705
|
+
return { ok: false, error: err };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 从输出里取落盘路径;取不到就按目录差异找
|
|
709
|
+
let installer = null;
|
|
710
|
+
const m = text.match(/Installer downloaded:\s*(.+?)\s*$/m);
|
|
711
|
+
if (m) {
|
|
712
|
+
const candidate = m[1].trim().replace(/^["']|["']$/g, "");
|
|
713
|
+
installer = isAbsolute(candidate) ? candidate : join(dir, basename(candidate));
|
|
714
|
+
}
|
|
715
|
+
if (!installer) {
|
|
716
|
+
const after = await snapshotDir(dir);
|
|
717
|
+
const fresh = [];
|
|
718
|
+
for (const [name, mtime] of after) {
|
|
719
|
+
if (!before.has(name) || before.get(name) !== mtime) fresh.push(name);
|
|
720
|
+
}
|
|
721
|
+
const pick = fresh.find((n) => INSTALLER_EXT.test(n)) || fresh.find((n) => /\.ya?ml$/i.test(n));
|
|
722
|
+
if (pick) installer = join(dir, pick);
|
|
723
|
+
}
|
|
724
|
+
if (!installer) return { ok: false, error: "下载完成但没找到安装包文件" };
|
|
725
|
+
|
|
726
|
+
let manifest = null;
|
|
727
|
+
const manifestGuess = installer.replace(INSTALLER_EXT, ".yaml");
|
|
728
|
+
try {
|
|
729
|
+
const st = await stat(manifestGuess);
|
|
730
|
+
if (st.isFile()) manifest = manifestGuess;
|
|
731
|
+
} catch {
|
|
732
|
+
const after = await snapshotDir(dir);
|
|
733
|
+
const yaml = [...after.keys()].find((n) => /\.ya?ml$/i.test(n) && n.includes(basename(installer).split("_")[0]));
|
|
734
|
+
if (yaml) manifest = join(dir, yaml);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let info = {};
|
|
738
|
+
if (manifest) {
|
|
739
|
+
try {
|
|
740
|
+
info = parseInstallerManifest(await readFile(manifest, "utf8"));
|
|
741
|
+
} catch {
|
|
742
|
+
info = {};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return { ok: true, installer, manifest, info };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** 执行安装包(静默)。 */
|
|
749
|
+
async function runInstaller(installer, info, log, signal, ctx = {}) {
|
|
750
|
+
const ext = String(installer).toLowerCase();
|
|
751
|
+
const isMsi = ext.endsWith(".msi");
|
|
752
|
+
const run = ctx.run || runProcess;
|
|
753
|
+
const args = resolveInstallArgs(info, installer);
|
|
754
|
+
const exe = isMsi ? join(process.env.SystemRoot || "C:\\Windows", "System32", "msiexec.exe") : installer;
|
|
755
|
+
// 安装失败时唯一能说明原因的现场就是 MSI 自己的日志(Error 1925 / 1603 …),
|
|
756
|
+
// 所以顺手开一份;日志路径由调用方提供一个可写目录(临时目录里没有空格)。
|
|
757
|
+
const msiLog = isMsi && ctx.scratchDir
|
|
758
|
+
? join(ctx.scratchDir, `${basename(installer).replace(/[^\w.-]+/g, "_")}.msi.log`)
|
|
759
|
+
: "";
|
|
760
|
+
const finalArgs = isMsi ? ["/i", installer, ...args, ...(msiLog ? ["/l*v", msiLog] : [])] : args;
|
|
761
|
+
log("info", `静默安装:${basename(installer)} ${finalArgs.join(" ")}`.trim());
|
|
762
|
+
if (ctx.elevated) log("info", "安装步骤在管理员上下文中执行(自动提权)");
|
|
763
|
+
const res = await run(exe, finalArgs, { timeoutMs: 60 * 60 * 1000, signal });
|
|
764
|
+
const text = outputText(res);
|
|
765
|
+
if (res.spawnError) return { ok: false, error: res.spawnError, code: null };
|
|
766
|
+
if (res.timedOut) return { ok: false, error: "安装超时(60 分钟)", code: null };
|
|
767
|
+
if (res.code !== 0) {
|
|
768
|
+
// 3010 = 需要重启,属成功
|
|
769
|
+
if (res.code === 3010) return { ok: true, code: res.code, note: "安装完成,需重启生效" };
|
|
770
|
+
const detail = msiLog && existsSync(msiLog) ? `;MSI 日志:${msiLog}` : "";
|
|
771
|
+
return { ok: false, error: explainInstallerExitCode(res.code, text) + detail, code: res.code };
|
|
772
|
+
}
|
|
773
|
+
return { ok: true, code: 0 };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* 用 winget upgrade 兜底(同样是官网全量包,安装由 winget 执行)。
|
|
778
|
+
*
|
|
779
|
+
* 两道修正:
|
|
780
|
+
* 1) 带上 `--include-unknown`:已装版本被 winget 读成 Unknown 的应用(虎牙直播)
|
|
781
|
+
* 不带它就直接「目录里没有适用的更新」;
|
|
782
|
+
* 2) 带架构过滤而失败时退回不限架构再试一次(清单里只有通用 exe 的应用)。
|
|
783
|
+
*/
|
|
784
|
+
async function wingetUpgradeFallback(wingetPath, id, log, signal, opts = {}) {
|
|
785
|
+
const env = opts.env || currentEnvironment();
|
|
786
|
+
const run = opts.run || runWinget;
|
|
787
|
+
const archFlag = opts.matchArch === false ? "" : env.wingetArch || "";
|
|
788
|
+
|
|
789
|
+
const attempt = async (flag) => {
|
|
790
|
+
log("warn", `改用 winget upgrade 兜底:${id}(${flag ? "--architecture " + flag : "不限架构"})`);
|
|
791
|
+
const args = [
|
|
792
|
+
"upgrade",
|
|
793
|
+
"--id",
|
|
794
|
+
id,
|
|
795
|
+
"--exact",
|
|
796
|
+
"--silent",
|
|
797
|
+
"--include-unknown",
|
|
798
|
+
"--accept-package-agreements",
|
|
799
|
+
"--accept-source-agreements",
|
|
800
|
+
"--disable-interactivity",
|
|
801
|
+
];
|
|
802
|
+
if (flag) args.push("--architecture", flag);
|
|
803
|
+
const res = await run(wingetPath, args, { timeoutMs: 60 * 60 * 1000, signal });
|
|
804
|
+
return { res, text: outputText(res) };
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
let { res, text } = await attempt(archFlag);
|
|
808
|
+
if (!res.ok && archFlag && isArchNotFoundError(text)) {
|
|
809
|
+
// 清单里没有本机架构的安装包 → 交回 winget 自己挑
|
|
810
|
+
({ res, text } = await attempt(""));
|
|
811
|
+
}
|
|
812
|
+
if (res.ok) return { ok: true };
|
|
813
|
+
const code = res.code;
|
|
814
|
+
if (code === 3010) return { ok: true, note: "安装完成,需重启生效" };
|
|
815
|
+
if ((code >>> 0) === 0x8a15002b) return { ok: false, error: "目录里没有适用的更新" };
|
|
816
|
+
return { ok: false, error: classifyWingetError(text) || `winget upgrade 退出码 ${code}` };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** 安装后复核版本。 */
|
|
820
|
+
async function readInstalledVersion(wingetPath, id, signal) {
|
|
821
|
+
if (!wingetPath || id.startsWith("reg:")) return "";
|
|
822
|
+
const res = await runWinget(
|
|
823
|
+
wingetPath,
|
|
824
|
+
["list", "--id", id, "--exact", "--accept-source-agreements", "--disable-interactivity"],
|
|
825
|
+
{ timeoutMs: 60000, signal }
|
|
826
|
+
);
|
|
827
|
+
const rows = parseInstalledList(res.stdout);
|
|
828
|
+
const row = rows.find((r) => r.id.toLowerCase() === id.toLowerCase()) || rows[0];
|
|
829
|
+
return row ? row.version : "";
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function classifyWingetError(text) {
|
|
833
|
+
const raw = String(text || "");
|
|
834
|
+
if (!raw.trim()) return "";
|
|
835
|
+
if (/hash does not match|哈希不匹配|Installer hash/i.test(raw)) {
|
|
836
|
+
return "官网安装包的哈希与软件目录记录不一致(发布方更新了安装包),可改用 winget upgrade 兜底,或以管理员身份执行 winget settings --enable InstallerHashOverride 后重试";
|
|
837
|
+
}
|
|
838
|
+
if (/DownloadCommandProhibited/i.test(raw)) return "该软件的清单禁止直接下载,只能通过 winget upgrade 安装";
|
|
839
|
+
if (/requires administrator|需要管理员|elevat/i.test(raw)) return "需要管理员权限(会弹出 UAC 确认)";
|
|
840
|
+
if (/No available upgrade found|No newer package/i.test(raw)) return "目录里没有适用的更新";
|
|
841
|
+
if (/not found|找不到|No package found/i.test(raw)) return "软件目录里找不到该包";
|
|
842
|
+
if (/被拒绝访问|Access is denied/i.test(raw)) return "访问被拒绝(可能需要以管理员身份运行)";
|
|
843
|
+
const line = raw.split("\n").map((l) => l.trim()).filter((l) => l && !/^[-=]+$/.test(l)).pop() || "";
|
|
844
|
+
return line.slice(0, 300);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* 串行执行一批更新。
|
|
849
|
+
* @param {object} p
|
|
850
|
+
* @param {string[]} p.ids
|
|
851
|
+
* @param {object} p.settings
|
|
852
|
+
* @param {(level: string, text: string) => void} p.log
|
|
853
|
+
* @param {(patch: object) => void} p.onProgress 进度回调(写进 state.update)
|
|
854
|
+
* @param {AbortSignal} p.signal
|
|
855
|
+
*/
|
|
856
|
+
export async function runUpdates({ ids, settings, log, onProgress, signal, plan = [] }) {
|
|
857
|
+
const env = currentEnvironment();
|
|
858
|
+
const matchArch = settings.matchSystemArch !== false;
|
|
859
|
+
|
|
860
|
+
if (env.family === "macos") {
|
|
861
|
+
return runMacosUpdates({ ids, settings, log, onProgress, signal, env });
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const wingetPath = findWinget();
|
|
865
|
+
const dir = resolveDownloadDir(settings);
|
|
866
|
+
const results = [];
|
|
867
|
+
const queue = [...new Set(ids.filter(Boolean))];
|
|
868
|
+
const planById = new Map(
|
|
869
|
+
(Array.isArray(plan) ? plan : [])
|
|
870
|
+
.map((item) => [String((item && item.id) || "").toLowerCase(), item])
|
|
871
|
+
.filter(([key]) => key)
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
// 提权:机器级安装(MSI / 写 Program Files)需要管理员令牌,而进程令牌创建后无法
|
|
875
|
+
// 原地提升,所以安装类命令交给提权副本执行(lib/elevate.js)。读版本、下载都不需要。
|
|
876
|
+
const autoElevate = settings.autoElevate !== false;
|
|
877
|
+
const elevatedNow = env.family === "windows" ? await isElevated({ signal }) : true;
|
|
878
|
+
const useElevation = env.family === "windows" && autoElevate && !elevatedNow && elevationAvailable();
|
|
879
|
+
const ctx = {
|
|
880
|
+
run: (exe, args, o) => (useElevation ? runElevated(exe, args, o) : runProcess(exe, args, o)),
|
|
881
|
+
runWinget: (exe, args, o) => (useElevation ? runWingetElevated(exe, args, o) : runWinget(exe, args, o)),
|
|
882
|
+
elevated: useElevation,
|
|
883
|
+
scratchDir: dir,
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
onProgress({ running: true, total: queue.length, done: 0, current: null, results: [], error: null, startedAt: Date.now() });
|
|
887
|
+
log("info", `开始更新 ${queue.length} 个应用(${describeEnvironment(env)},按架构匹配:${matchArch ? "是" : "否"})`);
|
|
888
|
+
if (env.family === "windows") {
|
|
889
|
+
if (elevatedNow) log("info", "当前进程已具备管理员权限");
|
|
890
|
+
else if (useElevation) log("info", "当前进程没有管理员权限:需要管理员的安装步骤会自动提权执行");
|
|
891
|
+
else if (autoElevate) log("warn", "当前进程没有管理员权限,且找不到 powershell.exe:无法自动提权,机器级安装会失败");
|
|
892
|
+
else log("warn", "当前进程没有管理员权限,且已关闭「自动提权安装」:机器级安装会失败(MSI 报 1925、安装器静默退出 0)");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// 「目标版本」表:安装后复核「到底升没升上去」的依据
|
|
896
|
+
const targets = await fetchUpgradeTargets(wingetPath, ctx, signal);
|
|
897
|
+
|
|
898
|
+
let index = 0;
|
|
899
|
+
for (const id of queue) {
|
|
900
|
+
if (signal.aborted) {
|
|
901
|
+
results.push({ id, ok: false, error: "已取消" });
|
|
902
|
+
log("warn", `已取消:${id}`);
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
index += 1;
|
|
906
|
+
onProgress({ running: true, total: queue.length, done: index - 1, current: id });
|
|
907
|
+
log("info", `(${index}/${queue.length})开始处理 ${id}`);
|
|
908
|
+
|
|
909
|
+
const startedAt = Date.now();
|
|
910
|
+
const target = targets.get(id.toLowerCase()) || "";
|
|
911
|
+
const before = (planById.get(id.toLowerCase()) || {}).version || "";
|
|
912
|
+
let entry = { id, ok: false, error: null, installer: null, fromCache: false, note: null, verified: null, durationMs: 0 };
|
|
913
|
+
|
|
914
|
+
try {
|
|
915
|
+
const selfUpdater = SELF_UPDATERS.get(id.toLowerCase());
|
|
916
|
+
let settled = false;
|
|
917
|
+
|
|
918
|
+
if (!wingetPath) {
|
|
919
|
+
entry.error = "未找到 winget,无法下载官网安装包";
|
|
920
|
+
} else if (selfUpdater && settings.useSelfUpdater !== false && !settings.dryRun) {
|
|
921
|
+
// 先让软件自带的更新服务试一次;没生效就退回官网全量安装包。
|
|
922
|
+
// Edge 的实测:EdgeUpdate 服务坏掉时 /ua 直接 0x80070002,而 winget 目录里的
|
|
923
|
+
// 官方安装包(企业版 MSI)在提权下能正常把 Edge 装到目标版本 —— winget 自己
|
|
924
|
+
// 拒绝执行只是因为「安装技术不同」这条策略。
|
|
925
|
+
entry.selfUpdater = selfUpdater;
|
|
926
|
+
const r = await runSelfUpdater(selfUpdater, log, signal, ctx);
|
|
927
|
+
entry.note = r.note || null;
|
|
928
|
+
if (r.ok) {
|
|
929
|
+
const check = await verifyInstalled({
|
|
930
|
+
wingetPath,
|
|
931
|
+
id,
|
|
932
|
+
before,
|
|
933
|
+
target,
|
|
934
|
+
signal,
|
|
935
|
+
log,
|
|
936
|
+
attempts: 12,
|
|
937
|
+
delayMs: 15000,
|
|
938
|
+
});
|
|
939
|
+
entry.verified = check.version || null;
|
|
940
|
+
if (check.changed || check.unverifiable) {
|
|
941
|
+
settled = true;
|
|
942
|
+
entry.ok = true;
|
|
943
|
+
entry.note = `${entry.note ? entry.note + ";" : ""}${check.changed ? "复核通过:" : "未复核:"}${check.reason}`;
|
|
944
|
+
log(check.changed ? "success" : "warn", `${id} ${check.changed ? "更新完成" : "已安装(未复核)"},${check.reason}`);
|
|
945
|
+
} else {
|
|
946
|
+
log("warn", `${id}:自带更新服务没有生效(${check.reason}),改用官网全量安装包`);
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
log("warn", `${id}:${r.error},改用官网全量安装包`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (settled) {
|
|
954
|
+
// 自带更新服务已经完成并复核过
|
|
955
|
+
} else if (entry.error || !wingetPath || signal.aborted) {
|
|
956
|
+
log("error", `${id} 更新失败:${entry.error}`);
|
|
957
|
+
} else {
|
|
958
|
+
const dl = await downloadInstaller(wingetPath, id, dir, log, signal, { env, matchArch });
|
|
959
|
+
if (!dl.ok) {
|
|
960
|
+
entry.error = dl.error;
|
|
961
|
+
} else if (settings.dryRun) {
|
|
962
|
+
// 演练模式:照常从官网下载全量安装包并落盘,但不执行安装。
|
|
963
|
+
entry.installer = dl.installer;
|
|
964
|
+
entry.manifest = dl.manifest || null;
|
|
965
|
+
entry.ok = true;
|
|
966
|
+
entry.dryRun = true;
|
|
967
|
+
entry.note = "演练模式:已下载官网全量安装包,未执行安装";
|
|
968
|
+
log("warn", `${id}:演练模式,已下载 ${basename(dl.installer)},跳过安装`);
|
|
969
|
+
} else {
|
|
970
|
+
entry.installer = dl.installer;
|
|
971
|
+
entry.manifest = dl.manifest || null;
|
|
972
|
+
const inst = await runInstaller(dl.installer, dl.info, log, signal, ctx);
|
|
973
|
+
if (inst.ok) {
|
|
974
|
+
entry.ok = true;
|
|
975
|
+
entry.installOk = true;
|
|
976
|
+
entry.note = inst.note || null;
|
|
977
|
+
} else {
|
|
978
|
+
entry.error = inst.error;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// 下载失败或安装失败 → winget upgrade 兜底(同样是官网全量包)
|
|
983
|
+
if (!entry.ok && !entry.dryRun && settings.hashFallbackUpgrade !== false && !signal.aborted) {
|
|
984
|
+
log("warn", `${id}:${entry.error},尝试 winget upgrade 兜底`);
|
|
985
|
+
const fb = await wingetUpgradeFallback(wingetPath, id, log, signal, { env, matchArch, run: ctx.runWinget });
|
|
986
|
+
if (fb.ok) {
|
|
987
|
+
entry.ok = true;
|
|
988
|
+
entry.installOk = true;
|
|
989
|
+
entry.error = null;
|
|
990
|
+
entry.note = fb.note || "已通过 winget 安装官网全量包";
|
|
991
|
+
} else {
|
|
992
|
+
entry.error = `${entry.error};兜底失败:${fb.error}`;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// 复核:退出码 0 ≠ 装上了。机器级安装包在没有管理员令牌时会静默退出 0 却什么都没装。
|
|
997
|
+
if (entry.ok && !entry.dryRun && !signal.aborted) {
|
|
998
|
+
const check = await verifyInstalled({
|
|
999
|
+
wingetPath,
|
|
1000
|
+
id,
|
|
1001
|
+
before,
|
|
1002
|
+
target,
|
|
1003
|
+
signal,
|
|
1004
|
+
log,
|
|
1005
|
+
attempts: 4,
|
|
1006
|
+
delayMs: 15000,
|
|
1007
|
+
});
|
|
1008
|
+
entry.verified = check.version || null;
|
|
1009
|
+
if (check.changed) {
|
|
1010
|
+
entry.note = `${entry.note ? entry.note + ";" : ""}复核通过:${check.reason}`;
|
|
1011
|
+
log("success", `${id} 更新完成,${check.reason}`);
|
|
1012
|
+
} else if (check.unverifiable) {
|
|
1013
|
+
// 读不到已装版本(卸载信息里没有版本号):按「已安装、未复核」处理,不能算失败
|
|
1014
|
+
entry.note = `${entry.note ? entry.note + ";" : ""}未复核:${check.reason}`;
|
|
1015
|
+
log("warn", `${id} 已安装,但无法复核版本:${check.reason}`);
|
|
1016
|
+
} else {
|
|
1017
|
+
entry.ok = false;
|
|
1018
|
+
const hint = updateHintFor(id);
|
|
1019
|
+
entry.error = `安装报告成功,但没有生效:${check.reason}${hint ? `。${hint}` : ""}`;
|
|
1020
|
+
log("error", `${id} 更新失败:${entry.error}`);
|
|
1021
|
+
}
|
|
1022
|
+
} else if (entry.ok && entry.dryRun) {
|
|
1023
|
+
// 演练模式没有真正安装,不做版本复核
|
|
1024
|
+
} else if (entry.ok) {
|
|
1025
|
+
log("success", `${id} 更新完成`);
|
|
1026
|
+
} else {
|
|
1027
|
+
log("error", `${id} 更新失败:${entry.error}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
entry.ok = false;
|
|
1032
|
+
entry.error = String((err && err.message) || err);
|
|
1033
|
+
log("error", `${id} 更新异常:${entry.error}`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1037
|
+
if (!settings.keepInstallers && entry.installer) {
|
|
1038
|
+
rm(entry.installer, { force: true }).catch(() => {});
|
|
1039
|
+
// winget download 会同时落一份 .yaml 清单,别让它留在下载目录里
|
|
1040
|
+
if (entry.manifest) rm(entry.manifest, { force: true }).catch(() => {});
|
|
1041
|
+
}
|
|
1042
|
+
results.push(entry);
|
|
1043
|
+
onProgress({ running: true, total: queue.length, done: index, current: null, results });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
1047
|
+
onProgress({ running: false, total: queue.length, done: results.length, current: null, results, finishedAt: Date.now(), error: null });
|
|
1048
|
+
log(okCount === results.length ? "success" : "warn", `更新结束:成功 ${okCount} / ${results.length}`);
|
|
1049
|
+
return results;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/* ------------------------------------------------------------------ *
|
|
1053
|
+
* macOS:Homebrew cask 引擎
|
|
1054
|
+
*
|
|
1055
|
+
* Homebrew 自己会按当前 Mac 的架构取对应的 cask(Apple Silicon 走 arm64,
|
|
1056
|
+
* Intel 走 x86_64),所以这里不需要再拼下载地址;我们只负责把「已装版本 →
|
|
1057
|
+
* 可升级版本」读出来,并在用户确认后执行 upgrade / fetch。
|
|
1058
|
+
* ------------------------------------------------------------------ */
|
|
1059
|
+
|
|
1060
|
+
export function findBrew(env = process.env) {
|
|
1061
|
+
const candidates = [];
|
|
1062
|
+
if (env.HOMEBREW_PREFIX) candidates.push(join(env.HOMEBREW_PREFIX, "bin", "brew"));
|
|
1063
|
+
candidates.push("/opt/homebrew/bin/brew", "/usr/local/bin/brew", "/home/linuxbrew/.linuxbrew/bin/brew");
|
|
1064
|
+
const pathValue = env.PATH || env.Path || "";
|
|
1065
|
+
for (const entry of String(pathValue).split(":")) {
|
|
1066
|
+
const dir = entry.trim();
|
|
1067
|
+
if (dir.startsWith("/")) candidates.push(join(dir, "brew"));
|
|
1068
|
+
}
|
|
1069
|
+
for (const file of candidates) {
|
|
1070
|
+
try {
|
|
1071
|
+
if (lstatSync(file).isFile()) return file;
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* 不可访问的候选路径 */
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function runBrew(brewPath, args, opts = {}) {
|
|
1080
|
+
return runProcess(brewPath, args, {
|
|
1081
|
+
timeoutMs: opts.timeoutMs || 20 * 60 * 1000,
|
|
1082
|
+
signal: opts.signal,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** `brew list --cask --versions` → Map(token → version)。 */
|
|
1087
|
+
export function parseBrewCaskVersions(text) {
|
|
1088
|
+
const out = new Map();
|
|
1089
|
+
for (const line of String(text || "").split("\n")) {
|
|
1090
|
+
const parts = line.trim().split(/\s+/);
|
|
1091
|
+
if (parts.length < 2) continue;
|
|
1092
|
+
out.set(parts[0], parts.slice(1).join(" "));
|
|
1093
|
+
}
|
|
1094
|
+
return out;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** `brew outdated --json=v2 --cask --greedy` → Map(token → 可用版本)。 */
|
|
1098
|
+
export function parseBrewOutdated(text) {
|
|
1099
|
+
const out = new Map();
|
|
1100
|
+
let json = null;
|
|
1101
|
+
try {
|
|
1102
|
+
json = JSON.parse(String(text || "").trim() || "{}");
|
|
1103
|
+
} catch {
|
|
1104
|
+
return out;
|
|
1105
|
+
}
|
|
1106
|
+
const casks = Array.isArray(json && json.casks) ? json.casks : [];
|
|
1107
|
+
for (const c of casks) {
|
|
1108
|
+
if (!c || !c.name) continue;
|
|
1109
|
+
const current = c.current_version || (Array.isArray(c.current_versions) ? c.current_versions[0] : "") || "";
|
|
1110
|
+
out.set(String(c.name), String(current));
|
|
1111
|
+
}
|
|
1112
|
+
return out;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function scanMacos({ settings, log, signal, env, startedAt, warnings }) {
|
|
1116
|
+
const brewPath = findBrew();
|
|
1117
|
+
|
|
1118
|
+
/* ---------------------------------------------------------------- *
|
|
1119
|
+
* 1. 先枚举「本机到底装了哪些应用」——这一步不依赖 Homebrew。
|
|
1120
|
+
* 旧实现只读 `brew list --cask`,没走 Homebrew 装的应用一个都看不到,
|
|
1121
|
+
* 于是正常 Mac 上就是「未检测到任何已安装应用」。
|
|
1122
|
+
* ---------------------------------------------------------------- */
|
|
1123
|
+
log("info", "正在枚举应用目录(/Applications 等)…");
|
|
1124
|
+
const apps = await scanAppBundles({ log });
|
|
1125
|
+
|
|
1126
|
+
/* ---------------------------------------------------------------- *
|
|
1127
|
+
* 2. Homebrew:哪些 cask 已装、哪些有新版(没装 Homebrew 也继续)。
|
|
1128
|
+
* ---------------------------------------------------------------- */
|
|
1129
|
+
let brewVersion = "";
|
|
1130
|
+
const brews = new Map();
|
|
1131
|
+
const outdated = new Map();
|
|
1132
|
+
|
|
1133
|
+
if (brewPath) {
|
|
1134
|
+
const verRes = await runBrew(brewPath, ["--version"], { timeoutMs: 60000 });
|
|
1135
|
+
brewVersion = firstLine(outputText(verRes)).replace(/^Homebrew\s+/i, "");
|
|
1136
|
+
log("info", `Homebrew ${brewVersion}(${brewPath})· ${env.archLabel}`);
|
|
1137
|
+
|
|
1138
|
+
/*
|
|
1139
|
+
* 注意:brew 退出码非 0 时 stdout 通常是空的(错误都在 stderr),旧实现直接把它
|
|
1140
|
+
* 当成「0 个已安装 cask」记日志,于是真正的故障(例如 brew 卡在 Portable Ruby
|
|
1141
|
+
* 升级、后台残留进程占用锁文件)被伪装成了「本机没有应用」。这里必须把失败讲出来。
|
|
1142
|
+
*/
|
|
1143
|
+
const listRes = await runBrew(brewPath, ["list", "--cask", "--versions"], { signal, timeoutMs: 5 * 60 * 1000 });
|
|
1144
|
+
if (listRes.ok) {
|
|
1145
|
+
for (const [token, version] of parseBrewCaskVersions(listRes.stdout)) brews.set(token, version);
|
|
1146
|
+
} else if (signal && signal.aborted) {
|
|
1147
|
+
log("info", "已取消:跳过 Homebrew 已装列表");
|
|
1148
|
+
} else {
|
|
1149
|
+
warnings.push(
|
|
1150
|
+
`Homebrew 列表命令失败(${firstLine(outputText(listRes)) || `exit ${listRes.code}`}):本次无法判断哪些应用由 Homebrew 管理、也无法判断是否有新版本。可先在终端执行 \`brew list --cask\` 检查 Homebrew 是否正常`
|
|
1151
|
+
);
|
|
1152
|
+
log("warn", warnings[warnings.length - 1]);
|
|
1153
|
+
}
|
|
1154
|
+
log("info", `Homebrew 报告 ${brews.size} 个已安装 cask`);
|
|
1155
|
+
|
|
1156
|
+
const outRes = await runBrew(brewPath, ["outdated", "--json=v2", "--cask", "--greedy"], { signal, timeoutMs: 10 * 60 * 1000 });
|
|
1157
|
+
/*
|
|
1158
|
+
* 用户按「取消」时 runProcess 会 resolve 成 {ok:false, code:null} —— 那是主动取消,
|
|
1159
|
+
* 不是 Homebrew 坏了,必须区别对待,否则取消操作会被当成故障报出来。
|
|
1160
|
+
*/
|
|
1161
|
+
if (!outRes.ok && signal && signal.aborted) {
|
|
1162
|
+
log("info", "已取消:跳过 Homebrew 更新检查");
|
|
1163
|
+
} else if (outRes.ok) {
|
|
1164
|
+
for (const [token, version] of parseBrewOutdated(outRes.stdout)) outdated.set(token, version);
|
|
1165
|
+
} else {
|
|
1166
|
+
/* 不用 `outRes.ok || outRes.stdout`:超时被杀时 stdout 可能只有半截内容,
|
|
1167
|
+
字符串真值会让「解析出 0 条」伪装成成功,于是漏报更新却不发警告。 */
|
|
1168
|
+
warnings.push(`Homebrew 更新检查失败(${firstLine(outputText(outRes)) || `exit ${outRes.code}`}):本次可能漏报可更新应用`);
|
|
1169
|
+
log("warn", warnings[warnings.length - 1]);
|
|
1170
|
+
}
|
|
1171
|
+
log("info", `其中 ${outdated.size} 个有新版本`);
|
|
1172
|
+
} else {
|
|
1173
|
+
warnings.push("未找到 Homebrew:可以列出已安装应用,但无法判断/驱动更新(安装 Homebrew 后可一键升级 cask 应用)");
|
|
1174
|
+
log("warn", warnings[warnings.length - 1]);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/* ---------------------------------------------------------------- *
|
|
1178
|
+
* 3. cask 目录:把 .app 反查到 cask token,才能知道官网最新版本。
|
|
1179
|
+
* 抓不到就降级(只显示 Homebrew 自己报告的信息),不中断扫描。
|
|
1180
|
+
* ---------------------------------------------------------------- */
|
|
1181
|
+
let catalog = null;
|
|
1182
|
+
if (brewPath) {
|
|
1183
|
+
const list = await fetchCaskCatalog({ log, warnings });
|
|
1184
|
+
if (list) catalog = parseCaskCatalog(list);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/* ---------------------------------------------------------------- *
|
|
1188
|
+
* 4. 合并:已装应用 + Homebrew 管理状态 → 最终列表
|
|
1189
|
+
* ---------------------------------------------------------------- */
|
|
1190
|
+
const built = buildMacosItems({ apps, brews, outdated, catalog, settings });
|
|
1191
|
+
const items = built.items;
|
|
1192
|
+
for (const w of built.warnings) if (!warnings.includes(w)) warnings.push(w);
|
|
1193
|
+
|
|
1194
|
+
const stats = {
|
|
1195
|
+
total: items.length,
|
|
1196
|
+
upgradable: items.filter((i) => i.hasUpdate).length,
|
|
1197
|
+
unsupported: items.filter((i) => !i.updateSupported).length,
|
|
1198
|
+
};
|
|
1199
|
+
log("success", `扫描完成:${stats.total} 个应用,${stats.upgradable} 个可更新`);
|
|
1200
|
+
|
|
1201
|
+
/*
|
|
1202
|
+
* 这里只是**按设计**如实说明「App Store 应用不由本插件升级」,不是故障。
|
|
1203
|
+
* 它以前被 push 进 warnings,而新界面把 warnings 渲染成红色告警横幅 ——
|
|
1204
|
+
* 于是每次正常扫描都会永久挂一条红条,把真正的故障(brew 失败 / 目录抓不到)
|
|
1205
|
+
* 稀释成噪音。日常说明走 info 日志,warnings 只留「本次判断依据失效」。
|
|
1206
|
+
*/
|
|
1207
|
+
if (settings.includeStoreApps) {
|
|
1208
|
+
log("info", "App Store 应用只列出、不由本插件更新(由 App Store 自己负责升级)");
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
env,
|
|
1213
|
+
engine: { id: ENGINE_BREW, available: Boolean(brewPath), version: brewVersion, path: brewPath || "" },
|
|
1214
|
+
scannedAt: Date.now(),
|
|
1215
|
+
startedAt,
|
|
1216
|
+
stats,
|
|
1217
|
+
items,
|
|
1218
|
+
warnings,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async function runMacosUpdates({ ids, settings, log, onProgress, signal, env }) {
|
|
1223
|
+
const brewPath = findBrew();
|
|
1224
|
+
const results = [];
|
|
1225
|
+
const queue = [...new Set((ids || []).filter(Boolean))];
|
|
1226
|
+
|
|
1227
|
+
onProgress({ running: true, total: queue.length, done: 0, current: null, results: [], error: null, startedAt: Date.now() });
|
|
1228
|
+
log("info", `开始更新 ${queue.length} 个应用(${describeEnvironment(env)},引擎:Homebrew)`);
|
|
1229
|
+
|
|
1230
|
+
if (!brewPath) {
|
|
1231
|
+
for (const id of queue) {
|
|
1232
|
+
results.push({ id, ok: false, error: "未找到 Homebrew,无法更新;请先安装 Homebrew" });
|
|
1233
|
+
log("error", `${id} 更新失败:未找到 Homebrew`);
|
|
1234
|
+
}
|
|
1235
|
+
onProgress({ running: false, total: queue.length, done: results.length, current: null, results, finishedAt: Date.now(), error: null });
|
|
1236
|
+
return results;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
let index = 0;
|
|
1240
|
+
for (const rawId of queue) {
|
|
1241
|
+
if (signal.aborted) {
|
|
1242
|
+
results.push({ id: rawId, ok: false, error: "已取消" });
|
|
1243
|
+
log("warn", `已取消:${rawId}`);
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
// 非 Homebrew 管理的应用(macapp:…)没有可交给 brew 的 token:
|
|
1247
|
+
// 明确报「不由本插件更新」,而不是把一个 bundle id 当 cask 名去执行。
|
|
1248
|
+
if (!String(rawId).startsWith("brew:")) {
|
|
1249
|
+
index += 1;
|
|
1250
|
+
results.push({
|
|
1251
|
+
id: rawId,
|
|
1252
|
+
ok: false,
|
|
1253
|
+
error: "该应用不由 Homebrew 管理(拖 DMG / 手动安装),本插件只能列出、不能更新它;请在应用内或官网升级",
|
|
1254
|
+
});
|
|
1255
|
+
log("warn", `${rawId} 跳过:非 Homebrew 管理的应用`);
|
|
1256
|
+
onProgress({ running: true, total: queue.length, done: index, current: null, results });
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
index += 1;
|
|
1260
|
+
const token = String(rawId).replace(/^brew:/, "");
|
|
1261
|
+
onProgress({ running: true, total: queue.length, done: index - 1, current: token });
|
|
1262
|
+
log("info", `(${index}/${queue.length})开始处理 ${token}`);
|
|
1263
|
+
|
|
1264
|
+
const startedAt = Date.now();
|
|
1265
|
+
const entry = { id: rawId, ok: false, error: null, installer: null, note: null, verified: null, durationMs: 0 };
|
|
1266
|
+
try {
|
|
1267
|
+
if (settings.dryRun) {
|
|
1268
|
+
// 演练模式:只下载该 cask 对应的安装包(Homebrew 按本机架构取),不安装
|
|
1269
|
+
log("warn", `${token}:演练模式,只下载安装包不安装`);
|
|
1270
|
+
const fetchRes = await runBrew(brewPath, ["fetch", "--cask", token], { signal, timeoutMs: 60 * 60 * 1000 });
|
|
1271
|
+
if (fetchRes.ok) {
|
|
1272
|
+
entry.ok = true;
|
|
1273
|
+
entry.dryRun = true;
|
|
1274
|
+
entry.note = "演练模式:已下载该应用对应的安装包,未安装";
|
|
1275
|
+
} else {
|
|
1276
|
+
entry.error = firstLine(outputText(fetchRes)) || `brew fetch 退出码 ${fetchRes.code}`;
|
|
1277
|
+
}
|
|
1278
|
+
} else {
|
|
1279
|
+
log("info", `brew upgrade --cask ${token}`);
|
|
1280
|
+
const upRes = await runBrew(brewPath, ["upgrade", "--cask", token], { signal, timeoutMs: 2 * 60 * 60 * 1000 });
|
|
1281
|
+
if (upRes.ok) {
|
|
1282
|
+
entry.ok = true;
|
|
1283
|
+
} else {
|
|
1284
|
+
entry.error = firstLine(outputText(upRes)) || `brew upgrade 退出码 ${upRes.code}`;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (entry.ok) {
|
|
1289
|
+
const listRes = await runBrew(brewPath, ["list", "--cask", "--versions", token], { signal, timeoutMs: 60000 });
|
|
1290
|
+
const now = parseBrewCaskVersions(listRes.stdout).get(token) || "";
|
|
1291
|
+
if (now) {
|
|
1292
|
+
entry.verified = now;
|
|
1293
|
+
log("success", `${token} 更新完成,当前版本 ${now}`);
|
|
1294
|
+
} else {
|
|
1295
|
+
log("success", `${token} 更新完成`);
|
|
1296
|
+
}
|
|
1297
|
+
} else {
|
|
1298
|
+
log("error", `${token} 更新失败:${entry.error}`);
|
|
1299
|
+
}
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
entry.ok = false;
|
|
1302
|
+
entry.error = String((err && err.message) || err);
|
|
1303
|
+
log("error", `${token} 更新异常:${entry.error}`);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1307
|
+
results.push(entry);
|
|
1308
|
+
onProgress({ running: true, total: queue.length, done: index, current: null, results });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
1312
|
+
onProgress({ running: false, total: queue.length, done: results.length, current: null, results, finishedAt: Date.now(), error: null });
|
|
1313
|
+
log(okCount === results.length ? "success" : "warn", `更新结束:成功 ${okCount} / ${results.length}`);
|
|
1314
|
+
return results;
|
|
1315
|
+
}
|