dsh-app-updater 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js CHANGED
@@ -10,11 +10,12 @@
10
10
  */
11
11
 
12
12
  import { mkdir, readdir, stat, readFile, rm } from "node:fs/promises";
13
- import { lstatSync } from "node:fs";
13
+ import { existsSync, lstatSync } from "node:fs";
14
14
  import { join, basename, isAbsolute } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
 
17
17
  import { runProcess, outputText } from "./proc.js";
18
+ import { elevationAvailable, isElevated, runElevated } from "./elevate.js";
18
19
  import {
19
20
  findWinget,
20
21
  runWinget,
@@ -426,6 +427,219 @@ export async function scanApps({ settings, log, signal }) {
426
427
  };
427
428
  }
428
429
 
430
+ /* ------------------------------------------------------------------ *
431
+ * 提权、复核与「自带更新服务」
432
+ * ------------------------------------------------------------------ */
433
+
434
+ /**
435
+ * winget 报「清单里没有可用安装包」一类错误(本机架构没有对应安装包)。
436
+ * 中文输出是「找不到适用的安装程序」,英文是 “No applicable installer found”。
437
+ *
438
+ * 这类失败必须退回「不限架构」再来一次:虎牙直播(Huya.Huya)的清单里只有
439
+ * 一个不标架构的通用 exe,带上 `--architecture x64` 就永远找不到。
440
+ */
441
+ export function isArchNotFoundError(text) {
442
+ const raw = String(text || "");
443
+ if (!raw) return false;
444
+ return /architecture|架构|applicable installer|not applicable|找不到适用|没有适用/i.test(raw);
445
+ }
446
+
447
+ /** 安装器退出码 → 人话。输出为空时兜底;MSI 的真实原因在它自己的日志里。 */
448
+ export function explainInstallerExitCode(code, text) {
449
+ const first = firstLine(text);
450
+ if (first) return first;
451
+ const map = {
452
+ 1603: "安装程序返回 1603(安装失败:常见原因是权限不足、文件被占用或已装其它版本;MSI 详情见日志)",
453
+ 1619: "安装程序返回 1619(安装包无法打开)",
454
+ 1620: "安装程序返回 1620(安装包损坏或不可读)",
455
+ 1625: "安装程序返回 1625(被系统策略禁止)",
456
+ 1638: "安装程序返回 1638(已安装其它版本)",
457
+ 1730: "安装程序返回 1730(卸载/安装需要管理员权限)",
458
+ };
459
+ return map[code] || `安装程序退出码 ${code}`;
460
+ }
461
+
462
+ /**
463
+ * 判断一次更新是否真的生效。
464
+ *
465
+ * 只看安装器退出码是不够的:机器级安装包在没有管理员令牌时会**静默退出 0**
466
+ * 却什么都没装(实测 Logitech G HUB 2026.6.981214 → 目标 2026.7.992920,
467
+ * 退出码 0,Program Files 里的文件与 winget 报的版本都没有变化)。
468
+ * 所以拿「安装前版本 / 安装后版本 / 目标版本」三个值判断,判不出来才算失败。
469
+ *
470
+ * @returns {{changed: boolean, reason: string}}
471
+ */
472
+ export function evaluateUpdateResult({ before, after, target }) {
473
+ const a = String(after == null ? "" : after).trim();
474
+ const b = String(before == null ? "" : before).trim();
475
+ const t = String(target == null ? "" : target).trim();
476
+ // winget 读不到已装版本(虎牙直播的卸载信息里就没有版本号)→ 无法复核:
477
+ // 既不能判生效也不能判失败,交给调用方按「已安装、未复核」处理。
478
+ if (!looksLikeVersion(a)) {
479
+ return {
480
+ changed: false,
481
+ unverifiable: true,
482
+ reason: a ? `winget 读不到已装版本(显示为 ${a})` : "安装后读不到版本",
483
+ };
484
+ }
485
+ // 达到或超过目标版本 → 生效
486
+ if (t && !isNewer(t, a)) {
487
+ return { changed: true, reason: `版本已是 ${a}(目标 ${t})` };
488
+ }
489
+ // 目标版本未知时,只能看它是否真的变新了
490
+ if (b && looksLikeVersion(b) && isNewer(a, b)) {
491
+ return { changed: true, reason: `版本从 ${b} 升到 ${a}` };
492
+ }
493
+ if (t) return { changed: false, reason: `版本没有变化(仍为 ${a},目标 ${t})` };
494
+ return { changed: false, reason: `版本没有变化(仍为 ${a})` };
495
+ }
496
+
497
+ /** 可取消的等待。 */
498
+ function delay(ms, signal) {
499
+ return new Promise((resolve) => {
500
+ if (signal && signal.aborted) {
501
+ resolve();
502
+ return;
503
+ }
504
+ const finish = () => {
505
+ clearTimeout(timer);
506
+ if (signal) signal.removeEventListener("abort", finish);
507
+ resolve();
508
+ };
509
+ const timer = setTimeout(finish, ms);
510
+ if (signal) signal.addEventListener("abort", finish, { once: true });
511
+ });
512
+ }
513
+
514
+ /** 提权跑 winget:winget 自己的安装动作同样需要管理员令牌才能装机器级包。 */
515
+ async function runWingetElevated(wingetPath, args, opts = {}) {
516
+ const res = await runElevated(wingetPath, args, { timeoutMs: opts.timeoutMs, signal: opts.signal });
517
+ return {
518
+ ok: res.ok,
519
+ code: res.code,
520
+ stdout: res.stdout || "",
521
+ stderr: "",
522
+ error: res.spawnError ? "spawn" : res.timedOut ? "timeout" : null,
523
+ };
524
+ }
525
+
526
+ /**
527
+ * 本轮的「目标版本」表:复核「到底升没升上去」的依据。
528
+ * 用一次 `winget upgrade --include-unknown` 拿全量(含已装版本为 Unknown 的应用)。
529
+ */
530
+ async function fetchUpgradeTargets(wingetPath, ctx, signal) {
531
+ const map = new Map();
532
+ if (!wingetPath) return map;
533
+ try {
534
+ const res = await ctx.runWinget(
535
+ wingetPath,
536
+ ["upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity"],
537
+ { timeoutMs: 240000, signal }
538
+ );
539
+ if (res.ok) {
540
+ for (const row of parseUpgradeList(res.stdout)) {
541
+ const key = String(row.id || "").toLowerCase();
542
+ if (key && row.available) map.set(key, row.available);
543
+ }
544
+ }
545
+ } catch {
546
+ // 拿不到目标版本时退化为「只看版本有没有变化」
547
+ }
548
+ return map;
549
+ }
550
+
551
+ /**
552
+ * 自带更新服务的软件:winget 目录里给的是**企业版**安装包,与本机的消费版
553
+ * 安装技术不匹配,winget 会直接拒绝(0x8A15008E「安装技术与当前安装的版本
554
+ * 不同,请卸载包并安装较新版本」)。这类软件交给它自己的更新服务。
555
+ */
556
+ const SELF_UPDATERS = new Map([
557
+ ["microsoft.edge", "edgeupdate"],
558
+ ["microsoft.edgewebview2runtime", "edgeupdate"],
559
+ ["microsoft.edgewebview", "edgeupdate"],
560
+ ]);
561
+
562
+ /**
563
+ * 少数软件的更新不由安装包完成,而是由它自己的常驻更新器在**应用启动时**应用
564
+ * (实测 Logitech G HUB:更新包早就下载好躺在 %ProgramData%\LGHUB\next.json 指向的
565
+ * depots 里,安装器 `--silent` 和 winget upgrade 都退出 0 什么都不做,只有启动
566
+ * G HUB 本体后 current.json 才跳到新版本)。
567
+ *
568
+ * 插件承诺「不需要打开被检测的软件」,所以这里只给提示,不替用户启动 app。
569
+ */
570
+ const UPDATE_HINTS = new Map([
571
+ [
572
+ "logitech.ghub",
573
+ "G HUB 的更新由它自己的更新器在「启动 G HUB」时应用:安装包与 winget 都可能退出 0 而不生效,手动打开一次 G HUB 即可完成升级(插件不会替你启动软件)",
574
+ ],
575
+ [
576
+ "microsoft.edge",
577
+ "Edge 由 EdgeUpdate 服务维护;该服务不可用时插件会退回安装官网全量包(企业版 MSI,提权执行)",
578
+ ],
579
+ ]);
580
+
581
+ /** 这条应用有没有「安装之外的更新方式」提示。 */
582
+ export function updateHintFor(id) {
583
+ return UPDATE_HINTS.get(String(id || "").toLowerCase()) || "";
584
+ }
585
+
586
+ /** 定位 Microsoft Edge 自带的更新服务。 */
587
+ export function findEdgeUpdate(env = process.env) {
588
+ const roots = [
589
+ env["ProgramFiles(x86)"],
590
+ env.ProgramFiles,
591
+ "C:\\Program Files (x86)",
592
+ "C:\\Program Files",
593
+ ].filter(Boolean);
594
+ for (const root of roots) {
595
+ const file = join(root, "Microsoft", "EdgeUpdate", "MicrosoftEdgeUpdate.exe");
596
+ try {
597
+ if (existsSync(file)) return file;
598
+ } catch {
599
+ // 受保护目录读不到时继续找下一个
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+
605
+ /**
606
+ * 触发自带更新服务(目前只有 Edge)。
607
+ * 它的退出码不代表版本已经就位(只是把任务交给服务),所以交给 verifyInstalled 轮询确认。
608
+ */
609
+ async function runSelfUpdater(kind, log, signal, ctx) {
610
+ if (kind !== "edgeupdate") return { ok: false, error: "未知的自带更新方式" };
611
+ const exe = findEdgeUpdate();
612
+ if (!exe) return { ok: false, error: "未找到 MicrosoftEdgeUpdate.exe(Edge 自带更新服务)" };
613
+ log("info", "改用 Edge 自带更新服务(winget 目录里是企业版 MSI,与本机消费版 Edge 的安装技术不匹配)");
614
+ const res = await ctx.run(exe, ["/ua"], { timeoutMs: 30 * 60 * 1000, signal });
615
+ if (res.spawnError) return { ok: false, error: res.spawnError };
616
+ if (res.timedOut) return { ok: false, error: "Edge 自带更新服务超时" };
617
+ return { ok: true, note: "已交给 Microsoft Edge 更新服务,等待版本就位" };
618
+ }
619
+
620
+ /**
621
+ * 安装后复核:读已装版本,与目标/安装前版本比对,必要时轮询等待
622
+ * (Edge 这类自带更新服务不是同步生效的)。
623
+ */
624
+ async function verifyInstalled({ wingetPath, id, before, target, signal, log, attempts = 1, delayMs = 15000 }) {
625
+ let version = "";
626
+ let verdict = { changed: false, reason: "尚未复核" };
627
+ const rounds = Math.max(1, attempts);
628
+ for (let i = 0; i < rounds; i += 1) {
629
+ if (signal && signal.aborted) break;
630
+ version = await readInstalledVersion(wingetPath, id, signal);
631
+ verdict = evaluateUpdateResult({ before, after: version, target });
632
+ if (verdict.changed || verdict.unverifiable) return { ...verdict, version };
633
+ if (i + 1 < rounds) {
634
+ if (log && i === 0) {
635
+ log("info", `${id}:暂未读到新版本(${version || "未知"}),继续等待…`);
636
+ }
637
+ await delay(delayMs, signal);
638
+ }
639
+ }
640
+ return { ...verdict, version };
641
+ }
642
+
429
643
  function firstLine(text) {
430
644
  const line = String(text || "").trim().split("\n")[0] || "";
431
645
  return line.slice(0, 200);
@@ -483,7 +697,7 @@ async function downloadInstaller(wingetPath, id, dir, log, signal, opts = {}) {
483
697
  if (!res.ok) {
484
698
  const err = classifyWingetError(text) || `winget download 退出码 ${res.code}`;
485
699
  // 指定架构失败(清单里没有这一架构的安装包)时,退回让 winget 自己挑,并如实记录
486
- if (archFlag && /architecture|no applicable installer|not applicable|没有适用/i.test(text)) {
700
+ if (archFlag && isArchNotFoundError(text)) {
487
701
  log("warn", `${id}:清单里没有 ${archFlag} 架构的安装包,改为自动匹配`);
488
702
  return downloadInstaller(wingetPath, id, dir, log, signal, { ...opts, matchArch: false });
489
703
  }
@@ -531,48 +745,73 @@ async function downloadInstaller(wingetPath, id, dir, log, signal, opts = {}) {
531
745
  }
532
746
 
533
747
  /** 执行安装包(静默)。 */
534
- async function runInstaller(installer, info, log, signal) {
748
+ async function runInstaller(installer, info, log, signal, ctx = {}) {
535
749
  const ext = String(installer).toLowerCase();
536
750
  const isMsi = ext.endsWith(".msi");
751
+ const run = ctx.run || runProcess;
537
752
  const args = resolveInstallArgs(info, installer);
538
753
  const exe = isMsi ? join(process.env.SystemRoot || "C:\\Windows", "System32", "msiexec.exe") : installer;
539
- const finalArgs = isMsi ? ["/i", installer, ...args] : args;
754
+ // 安装失败时唯一能说明原因的现场就是 MSI 自己的日志(Error 1925 / 1603 …),
755
+ // 所以顺手开一份;日志路径由调用方提供一个可写目录(临时目录里没有空格)。
756
+ const msiLog = isMsi && ctx.scratchDir
757
+ ? join(ctx.scratchDir, `${basename(installer).replace(/[^\w.-]+/g, "_")}.msi.log`)
758
+ : "";
759
+ const finalArgs = isMsi ? ["/i", installer, ...args, ...(msiLog ? ["/l*v", msiLog] : [])] : args;
540
760
  log("info", `静默安装:${basename(installer)} ${finalArgs.join(" ")}`.trim());
541
- const res = await runProcess(exe, finalArgs, { timeoutMs: 60 * 60 * 1000, signal });
761
+ if (ctx.elevated) log("info", "安装步骤在管理员上下文中执行(自动提权)");
762
+ const res = await run(exe, finalArgs, { timeoutMs: 60 * 60 * 1000, signal });
542
763
  const text = outputText(res);
543
764
  if (res.spawnError) return { ok: false, error: res.spawnError, code: null };
544
765
  if (res.timedOut) return { ok: false, error: "安装超时(60 分钟)", code: null };
545
766
  if (res.code !== 0) {
546
767
  // 3010 = 需要重启,属成功
547
768
  if (res.code === 3010) return { ok: true, code: res.code, note: "安装完成,需重启生效" };
548
- return { ok: false, error: firstLine(text) || `安装程序退出码 ${res.code}`, code: res.code };
769
+ const detail = msiLog && existsSync(msiLog) ? `;MSI 日志:${msiLog}` : "";
770
+ return { ok: false, error: explainInstallerExitCode(res.code, text) + detail, code: res.code };
549
771
  }
550
772
  return { ok: true, code: 0 };
551
773
  }
552
774
 
553
- /** 用 winget upgrade 兜底(同样是官网全量包,安装由 winget 执行)。 */
775
+ /**
776
+ * 用 winget upgrade 兜底(同样是官网全量包,安装由 winget 执行)。
777
+ *
778
+ * 两道修正:
779
+ * 1) 带上 `--include-unknown`:已装版本被 winget 读成 Unknown 的应用(虎牙直播)
780
+ * 不带它就直接「目录里没有适用的更新」;
781
+ * 2) 带架构过滤而失败时退回不限架构再试一次(清单里只有通用 exe 的应用)。
782
+ */
554
783
  async function wingetUpgradeFallback(wingetPath, id, log, signal, opts = {}) {
555
784
  const env = opts.env || currentEnvironment();
785
+ const run = opts.run || runWinget;
556
786
  const archFlag = opts.matchArch === false ? "" : env.wingetArch || "";
557
- log("warn", `改用 winget upgrade 兜底:${id}${archFlag ? "(--architecture " + archFlag + ")" : ""}`);
558
- const args = [
559
- "upgrade",
560
- "--id",
561
- id,
562
- "--exact",
563
- "--silent",
564
- "--accept-package-agreements",
565
- "--accept-source-agreements",
566
- "--disable-interactivity",
567
- ];
568
- if (archFlag) args.push("--architecture", archFlag);
569
- const res = await runWinget(wingetPath, args, { timeoutMs: 60 * 60 * 1000, signal });
570
- const text = outputText(res);
787
+
788
+ const attempt = async (flag) => {
789
+ log("warn", `改用 winget upgrade 兜底:${id}(${flag ? "--architecture " + flag : "不限架构"})`);
790
+ const args = [
791
+ "upgrade",
792
+ "--id",
793
+ id,
794
+ "--exact",
795
+ "--silent",
796
+ "--include-unknown",
797
+ "--accept-package-agreements",
798
+ "--accept-source-agreements",
799
+ "--disable-interactivity",
800
+ ];
801
+ if (flag) args.push("--architecture", flag);
802
+ const res = await run(wingetPath, args, { timeoutMs: 60 * 60 * 1000, signal });
803
+ return { res, text: outputText(res) };
804
+ };
805
+
806
+ let { res, text } = await attempt(archFlag);
807
+ if (!res.ok && archFlag && isArchNotFoundError(text)) {
808
+ // 清单里没有本机架构的安装包 → 交回 winget 自己挑
809
+ ({ res, text } = await attempt(""));
810
+ }
571
811
  if (res.ok) return { ok: true };
572
812
  const code = res.code;
573
- if (code === 3010 || (code >>> 0) === 0x8a15002b) {
574
- return code === 3010 ? { ok: true, note: "安装完成,需重启生效" } : { ok: false, error: "目录里没有适用的更新" };
575
- }
813
+ if (code === 3010) return { ok: true, note: "安装完成,需重启生效" };
814
+ if ((code >>> 0) === 0x8a15002b) return { ok: false, error: "目录里没有适用的更新" };
576
815
  return { ok: false, error: classifyWingetError(text) || `winget upgrade 退出码 ${code}` };
577
816
  }
578
817
 
@@ -613,7 +852,7 @@ function classifyWingetError(text) {
613
852
  * @param {(patch: object) => void} p.onProgress 进度回调(写进 state.update)
614
853
  * @param {AbortSignal} p.signal
615
854
  */
616
- export async function runUpdates({ ids, settings, log, onProgress, signal }) {
855
+ export async function runUpdates({ ids, settings, log, onProgress, signal, plan = [] }) {
617
856
  const env = currentEnvironment();
618
857
  const matchArch = settings.matchSystemArch !== false;
619
858
 
@@ -625,9 +864,35 @@ export async function runUpdates({ ids, settings, log, onProgress, signal }) {
625
864
  const dir = resolveDownloadDir(settings);
626
865
  const results = [];
627
866
  const queue = [...new Set(ids.filter(Boolean))];
867
+ const planById = new Map(
868
+ (Array.isArray(plan) ? plan : [])
869
+ .map((item) => [String((item && item.id) || "").toLowerCase(), item])
870
+ .filter(([key]) => key)
871
+ );
872
+
873
+ // 提权:机器级安装(MSI / 写 Program Files)需要管理员令牌,而进程令牌创建后无法
874
+ // 原地提升,所以安装类命令交给提权副本执行(lib/elevate.js)。读版本、下载都不需要。
875
+ const autoElevate = settings.autoElevate !== false;
876
+ const elevatedNow = env.family === "windows" ? await isElevated({ signal }) : true;
877
+ const useElevation = env.family === "windows" && autoElevate && !elevatedNow && elevationAvailable();
878
+ const ctx = {
879
+ run: (exe, args, o) => (useElevation ? runElevated(exe, args, o) : runProcess(exe, args, o)),
880
+ runWinget: (exe, args, o) => (useElevation ? runWingetElevated(exe, args, o) : runWinget(exe, args, o)),
881
+ elevated: useElevation,
882
+ scratchDir: dir,
883
+ };
628
884
 
629
885
  onProgress({ running: true, total: queue.length, done: 0, current: null, results: [], error: null, startedAt: Date.now() });
630
886
  log("info", `开始更新 ${queue.length} 个应用(${describeEnvironment(env)},按架构匹配:${matchArch ? "是" : "否"})`);
887
+ if (env.family === "windows") {
888
+ if (elevatedNow) log("info", "当前进程已具备管理员权限");
889
+ else if (useElevation) log("info", "当前进程没有管理员权限:需要管理员的安装步骤会自动提权执行");
890
+ else if (autoElevate) log("warn", "当前进程没有管理员权限,且找不到 powershell.exe:无法自动提权,机器级安装会失败");
891
+ else log("warn", "当前进程没有管理员权限,且已关闭「自动提权安装」:机器级安装会失败(MSI 报 1925、安装器静默退出 0)");
892
+ }
893
+
894
+ // 「目标版本」表:安装后复核「到底升没升上去」的依据
895
+ const targets = await fetchUpgradeTargets(wingetPath, ctx, signal);
631
896
 
632
897
  let index = 0;
633
898
  for (const id of queue) {
@@ -641,11 +906,53 @@ export async function runUpdates({ ids, settings, log, onProgress, signal }) {
641
906
  log("info", `(${index}/${queue.length})开始处理 ${id}`);
642
907
 
643
908
  const startedAt = Date.now();
909
+ const target = targets.get(id.toLowerCase()) || "";
910
+ const before = (planById.get(id.toLowerCase()) || {}).version || "";
644
911
  let entry = { id, ok: false, error: null, installer: null, fromCache: false, note: null, verified: null, durationMs: 0 };
645
912
 
646
913
  try {
914
+ const selfUpdater = SELF_UPDATERS.get(id.toLowerCase());
915
+ let settled = false;
916
+
647
917
  if (!wingetPath) {
648
918
  entry.error = "未找到 winget,无法下载官网安装包";
919
+ } else if (selfUpdater && settings.useSelfUpdater !== false && !settings.dryRun) {
920
+ // 先让软件自带的更新服务试一次;没生效就退回官网全量安装包。
921
+ // Edge 的实测:EdgeUpdate 服务坏掉时 /ua 直接 0x80070002,而 winget 目录里的
922
+ // 官方安装包(企业版 MSI)在提权下能正常把 Edge 装到目标版本 —— winget 自己
923
+ // 拒绝执行只是因为「安装技术不同」这条策略。
924
+ entry.selfUpdater = selfUpdater;
925
+ const r = await runSelfUpdater(selfUpdater, log, signal, ctx);
926
+ entry.note = r.note || null;
927
+ if (r.ok) {
928
+ const check = await verifyInstalled({
929
+ wingetPath,
930
+ id,
931
+ before,
932
+ target,
933
+ signal,
934
+ log,
935
+ attempts: 12,
936
+ delayMs: 15000,
937
+ });
938
+ entry.verified = check.version || null;
939
+ if (check.changed || check.unverifiable) {
940
+ settled = true;
941
+ entry.ok = true;
942
+ entry.note = `${entry.note ? entry.note + ";" : ""}${check.changed ? "复核通过:" : "未复核:"}${check.reason}`;
943
+ log(check.changed ? "success" : "warn", `${id} ${check.changed ? "更新完成" : "已安装(未复核)"},${check.reason}`);
944
+ } else {
945
+ log("warn", `${id}:自带更新服务没有生效(${check.reason}),改用官网全量安装包`);
946
+ }
947
+ } else {
948
+ log("warn", `${id}:${r.error},改用官网全量安装包`);
949
+ }
950
+ }
951
+
952
+ if (settled) {
953
+ // 自带更新服务已经完成并复核过
954
+ } else if (entry.error || !wingetPath || signal.aborted) {
955
+ log("error", `${id} 更新失败:${entry.error}`);
649
956
  } else {
650
957
  const dl = await downloadInstaller(wingetPath, id, dir, log, signal, { env, matchArch });
651
958
  if (!dl.ok) {
@@ -653,15 +960,18 @@ export async function runUpdates({ ids, settings, log, onProgress, signal }) {
653
960
  } else if (settings.dryRun) {
654
961
  // 演练模式:照常从官网下载全量安装包并落盘,但不执行安装。
655
962
  entry.installer = dl.installer;
963
+ entry.manifest = dl.manifest || null;
656
964
  entry.ok = true;
657
965
  entry.dryRun = true;
658
966
  entry.note = "演练模式:已下载官网全量安装包,未执行安装";
659
967
  log("warn", `${id}:演练模式,已下载 ${basename(dl.installer)},跳过安装`);
660
968
  } else {
661
969
  entry.installer = dl.installer;
662
- const inst = await runInstaller(dl.installer, dl.info, log, signal);
970
+ entry.manifest = dl.manifest || null;
971
+ const inst = await runInstaller(dl.installer, dl.info, log, signal, ctx);
663
972
  if (inst.ok) {
664
973
  entry.ok = true;
974
+ entry.installOk = true;
665
975
  entry.note = inst.note || null;
666
976
  } else {
667
977
  entry.error = inst.error;
@@ -671,27 +981,50 @@ export async function runUpdates({ ids, settings, log, onProgress, signal }) {
671
981
  // 下载失败或安装失败 → winget upgrade 兜底(同样是官网全量包)
672
982
  if (!entry.ok && !entry.dryRun && settings.hashFallbackUpgrade !== false && !signal.aborted) {
673
983
  log("warn", `${id}:${entry.error},尝试 winget upgrade 兜底`);
674
- const fb = await wingetUpgradeFallback(wingetPath, id, log, signal, { env, matchArch });
984
+ const fb = await wingetUpgradeFallback(wingetPath, id, log, signal, { env, matchArch, run: ctx.runWinget });
675
985
  if (fb.ok) {
676
986
  entry.ok = true;
987
+ entry.installOk = true;
677
988
  entry.error = null;
678
989
  entry.note = fb.note || "已通过 winget 安装官网全量包";
679
990
  } else {
680
991
  entry.error = `${entry.error};兜底失败:${fb.error}`;
681
992
  }
682
993
  }
683
- }
684
994
 
685
- if (entry.ok) {
686
- const version = await readInstalledVersion(wingetPath, id, signal);
687
- if (version) {
688
- entry.verified = version;
689
- log("success", `${id} 更新完成,当前版本 ${version}`);
690
- } else {
995
+ // 复核:退出码 0 ≠ 装上了。机器级安装包在没有管理员令牌时会静默退出 0 却什么都没装。
996
+ if (entry.ok && !entry.dryRun && !signal.aborted) {
997
+ const check = await verifyInstalled({
998
+ wingetPath,
999
+ id,
1000
+ before,
1001
+ target,
1002
+ signal,
1003
+ log,
1004
+ attempts: 4,
1005
+ delayMs: 15000,
1006
+ });
1007
+ entry.verified = check.version || null;
1008
+ if (check.changed) {
1009
+ entry.note = `${entry.note ? entry.note + ";" : ""}复核通过:${check.reason}`;
1010
+ log("success", `${id} 更新完成,${check.reason}`);
1011
+ } else if (check.unverifiable) {
1012
+ // 读不到已装版本(卸载信息里没有版本号):按「已安装、未复核」处理,不能算失败
1013
+ entry.note = `${entry.note ? entry.note + ";" : ""}未复核:${check.reason}`;
1014
+ log("warn", `${id} 已安装,但无法复核版本:${check.reason}`);
1015
+ } else {
1016
+ entry.ok = false;
1017
+ const hint = updateHintFor(id);
1018
+ entry.error = `安装报告成功,但没有生效:${check.reason}${hint ? `。${hint}` : ""}`;
1019
+ log("error", `${id} 更新失败:${entry.error}`);
1020
+ }
1021
+ } else if (entry.ok && entry.dryRun) {
1022
+ // 演练模式没有真正安装,不做版本复核
1023
+ } else if (entry.ok) {
691
1024
  log("success", `${id} 更新完成`);
1025
+ } else {
1026
+ log("error", `${id} 更新失败:${entry.error}`);
692
1027
  }
693
- } else {
694
- log("error", `${id} 更新失败:${entry.error}`);
695
1028
  }
696
1029
  } catch (err) {
697
1030
  entry.ok = false;
@@ -702,6 +1035,8 @@ export async function runUpdates({ ids, settings, log, onProgress, signal }) {
702
1035
  entry.durationMs = Date.now() - startedAt;
703
1036
  if (!settings.keepInstallers && entry.installer) {
704
1037
  rm(entry.installer, { force: true }).catch(() => {});
1038
+ // winget download 会同时落一份 .yaml 清单,别让它留在下载目录里
1039
+ if (entry.manifest) rm(entry.manifest, { force: true }).catch(() => {});
705
1040
  }
706
1041
  results.push(entry);
707
1042
  onProgress({ running: true, total: queue.length, done: index, current: null, results });