dsh-app-updater 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,62 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.3.0] — 2026-10-31
6
+
7
+ ### 修复
8
+
9
+ - **机器级安装全部失败的问题**:进程没有管理员令牌时,MSI 直接失败(msiexec 1603,
10
+ MSI 日志里是 `Error 1925: You do not have sufficient privileges…`),而部分安装器是
11
+ **静默退出 0 却什么都没装**(实测 Logitech G HUB)。两个现象同源,都是权限。
12
+ - **安装后复核**:退出码 0 不再等于成功。安装完成后用「安装前版本 / 安装后版本 / 目标版本」
13
+ 判断是否真的生效,没生效就如实报失败 —— 原来只看退出码,会把 G HUB 那种
14
+ 「退出码 0、版本没变」的情况误报成「更新完成」。
15
+ - **架构过滤回退**:`winget download/upgrade --architecture x64` 在清单里没有该架构时现在能
16
+ 正确识别(中文报错是「找不到适用的安装程序」,旧正则只匹配了「没有适用」)并退回不限架构
17
+ 重试;`winget upgrade` 兜底同时补上 `--include-unknown`(虎牙直播的已装版本被读成 Unknown,
18
+ 不带它直接「目录里没有适用的更新」)。
19
+ - **Edge**:winget 目录里的 `Microsoft.Edge` 是企业版 MSI,与本机消费版 Edge 的安装技术不匹配,
20
+ winget 直接拒绝(`0x8A15008E`「安装技术与当前安装的版本不同,请卸载包并安装较新版本」),
21
+ 现在先走 Edge 自带的更新服务 `MicrosoftEdgeUpdate.exe /ua`;若它没生效(实测该机的
22
+ EdgeUpdate 引擎目录是 `1.3.311.5` 而服务主程序还是 `1.3.275.13`,服务起不来,`/ua` 直接
23
+ `0x80070002`),自动退回安装 winget 目录里的官网全量包(企业版 MSI,提权执行)——
24
+ 实测这一步能把 Edge 正常装到目标版本,winget 拒绝执行只是因为它的策略。
25
+ - **读不到版本的应用**:虎牙直播的卸载信息里没有版本号,winget 永远显示 `Unknown`。
26
+ 复核阶段不再把它误判为失败,改为「已安装、未复核」写进结果与日志。
27
+ - **「安装包不生效」的软件给出可操作提示**:实测 Logitech G HUB 的更新包早已下载到
28
+ `%ProgramData%\LGHUB\next.json` 指向的 depots(build 880806 / 2026.7.992920),但安装器
29
+ `--silent` 与 `winget upgrade` 都会在 3 秒内退出 0 且什么都不装,只有**启动 G HUB 本体**
30
+ 之后 `current.json` 才跳到新版本。插件承诺不启动用户的软件,所以这类情况现在会在失败
31
+ 原因里直接给出「手动打开一次该应用即可完成升级」的提示(`updateHintFor`)。
32
+ - **MSI 日志**:MSI 安装现在带 `/l*v <下载目录>\<包名>.msi.log`,失败时把日志路径写进错误信息
33
+ (「Error 1925」就是这么被找到的)。
34
+ - **残留清单**:关闭「保留安装包」时,现在连 winget 落盘的 `.yaml` 清单一并删除。
35
+
36
+ ### 新增
37
+
38
+ - **自动提权安装**(`lib/elevate.js`):需要管理员的安装类命令交给 `Start-Process -Verb RunAs`
39
+ 的提权副本执行,退出码与输出写进临时文件后读回。新增开关「自动提权安装」(默认开启)。
40
+ UAC 为「始终通知」时每次安装会弹一次确认;把 `ConsentPromptBehaviorAdmin` 设为 0
41
+ (管理员静默提权)时完全无感。
42
+ - 说明:提权副本是独立进程,取消更新只会停掉启动器,已经开始的那次安装会自己跑完。
43
+
44
+ ### 安全
45
+
46
+ - **接入宿主 connection 准入**(插件市场 dshopen.org 对 1.2.0 的复核意见):所有
47
+ `/dsh-app-updater/*` 路由先过 `ctx.connection.admit(request)` —— DSH 用来做 Host/Origin
48
+ 信任校验与浏览器会话 / API 认证的那套边界,宿主自己的 `/api` 路由用的就是它。
49
+ 未通过准入的请求直接得到 `401`/`403`;带凭据的请求一律以准入结果为准。
50
+ 只有在宿主没有 `connection` 服务、或者请求不带任何连接凭据且来自回环地址时,才退回旧的
51
+ 「回环」规则,并在响应头标注 `X-DSH-Admission: loopback-legacy`(宿主自己的 Electron
52
+ 载体与本机脚本都不带 Cookie,需要一个不误伤它们的路径)。
53
+ - 因此 README 里「用 curl 直接改设置」的例子现在需要带上 DSH 的会话 Cookie,或在
54
+ `connection` 不可用的宿主上从回环地址调用。
55
+
56
+ ### 说明
57
+
58
+ - 读类命令(`winget list` / `upgrade --include-unknown` / `--version`)不提权。
59
+ - `update.results[]` 新增 `installOk`(安装器自身是否报成功)与 `manifest`(落盘的清单位置)。
60
+
5
61
  ## [1.2.0] — 2026-10-31
6
62
 
7
63
  ### 新增
package/CONTRACT.md CHANGED
@@ -27,10 +27,24 @@ dsh-app-updater/
27
27
  - 架构匹配:`settings.matchSystemArch !== false` 时,Windows 侧把 `env.wingetArch` 传给
28
28
  `winget download/upgrade --architecture`;清单缺该架构时自动退回自动匹配并写日志。
29
29
  macOS 侧由 Homebrew 决定 cask 架构。
30
+ - 提权(`lib/elevate.js`):`settings.autoElevate !== false` 且当前进程没有管理员令牌时,
31
+ 安装类命令(安装包本体、`winget upgrade` 兜底)交给 `Start-Process -Verb RunAs` 的提权副本
32
+ 执行,退出码与输出写进临时文件后读回;`cancelled` 表示 UAC 被取消(1223)。
33
+ 读类命令(`winget list` / `upgrade --include-unknown` / `--version`)不提权。
34
+ - 复核:安装后必须用「安装前版本 / 安装后版本 / 目标版本」判断是否真的生效,退出码 0 不算成功
35
+ (机器级安装包在未提权时会静默退出 0 却什么都没装)。`update.results[].installOk` 记录
36
+ 安装器自身是否报成功,`verified` 记录复核时读到的版本。
37
+ - 自带更新服务:Edge 的 winget 目录条目是企业版 MSI,与本机消费版安装技术不匹配
38
+ (`0x8A15008E`),改走 `MicrosoftEdgeUpdate.exe /ua`,再由复核轮询确认版本就位。
30
39
 
31
40
  ## 宿主 HTTP 路由
32
41
 
33
- 全部 `kind: "exact"`,前缀 `/dsh-app-updater/`。写操作要求 loopback + `confirm: true`。
42
+ 全部 `kind: "exact"`,前缀 `/dsh-app-updater/`。每个请求先过 **connection 准入**:
43
+ `ctx.connection.admit(req)`(DSH 的 Host/Origin 信任校验 + 浏览器会话 / API 认证,
44
+ 宿主自己的 `/api` 路由用的同一套),被拒即返回 `401`(未认证)/ `403`(不可信来源);
45
+ 写操作再要求 `confirm: true`。
46
+ 宿主没有 `connection` 服务、或请求不带任何连接凭据且来自回环地址时,退回旧的「回环」检查,
47
+ 并在响应头标注 `X-DSH-Admission: loopback-legacy`(`lib/index.js` 的 `admissionGate`)。
34
48
 
35
49
  ### GET /dsh-app-updater/state.json
36
50
 
@@ -48,7 +62,7 @@ dsh-app-updater/
48
62
  "update": { "running": false, "total": 0, "done": 0, "current": null, "error": null },
49
63
  "apps": { "scannedAt": 0, "stats": { "total": 0, "upgradable": 0, "unsupported": 0 }, "items": [], "engine": "winget" },
50
64
  "log": [ { "at": 0, "level": "info|warn|error|success", "text": "..." } ],
51
- "settings": { "includeStoreApps": true, "includeRegistryOnly": true, "downloadDir": "", "keepInstallers": true, "hashFallbackUpgrade": true, "dryRun": false, "matchSystemArch": true }
65
+ "settings": { "includeStoreApps": true, "includeRegistryOnly": true, "downloadDir": "", "keepInstallers": true, "hashFallbackUpgrade": true, "dryRun": false, "matchSystemArch": true, "autoElevate": true }
52
66
  }
53
67
  ```
54
68
 
@@ -81,11 +95,30 @@ body `{ "confirm": true, "force": true }` → `{ "ok": true, "started": true }`
81
95
  ### POST /dsh-app-updater/update
82
96
 
83
97
  body `{ "confirm": true, "ids": ["Google.Chrome", "..."], "mode": "silent" }`
84
- → `{ "ok": true, "started": true, "queued": 3 }`(串行执行,逐条下载官网全量包 → 校验 → 静默安装)
98
+ → `{ "ok": true, "started": true, "queued": 3 }`
99
+ (串行执行,逐条:下载官网全量包 → 校验哈希 → 静默安装(需要管理员时自动提权)→ 复核版本。
100
+ Edge 由 `MicrosoftEdgeUpdate.exe /ua` 处理。`settings.dryRun` 时只下载、不安装、不复核。)
101
+
102
+ `update.results[]`(每条一次更新):
103
+
104
+ ```jsonc
105
+ {
106
+ "id": "Microsoft.Edge",
107
+ "ok": false, // 最终结论:复核不通过就是 false,哪怕安装器报成功
108
+ "installOk": true, // 安装器自身是否报成功(退出码 0 / winget 成功)
109
+ "error": "安装报告成功,但没有生效:版本没有变化(仍为 156.0.4314.50,目标 156.0.4314.58)",
110
+ "installer": "C:\\...\\Microsoft Edge_156.0.4314.58_Machine_X64_msi_en-US.msi",
111
+ "manifest": "C:\\...\\Microsoft Edge_156.0.4314.58_Machine_X64_msi_en-US.yaml",
112
+ "verified": "156.0.4314.50", // 复核时读到的已装版本
113
+ "selfUpdater": "edgeupdate", // 走自带更新服务时存在
114
+ "note": "已交给 Microsoft Edge 更新服务,等待版本就位",
115
+ "durationMs": 33288
116
+ }
117
+ ```
85
118
 
86
119
  ### POST /dsh-app-updater/cancel
87
120
 
88
- → `{ "ok": true }`(取消尚未开始的项目;正在安装的进程会收到终止请求)
121
+ → `{ "ok": true }`(取消尚未开始的项目;提权副本是独立进程,取消只会停掉启动器,已经开始的那次安装会自己跑完)
89
122
 
90
123
  ### POST /dsh-app-updater/settings
91
124
 
package/README.en.md CHANGED
@@ -95,6 +95,7 @@ State file: `%USERPROFILE%\.dsh\dsh-app-updater\state.json`
95
95
  | `hashFallbackUpgrade` | `true` | Fall back to `winget upgrade` when the download fails. |
96
96
  | `dryRun` | `false` | **Rehearsal mode**: download the vendor's full installer but never install it. |
97
97
  | `matchSystemArch` | `true` | Match the installer to the detected system and CPU architecture (Windows passes `--architecture`; macOS is resolved by Homebrew). |
98
+ | `autoElevate` | `true` | **Automatic elevation**: install commands that need administrator rights run in an elevated copy (UAC prompts once per install, when UAC prompts at all). |
98
99
 
99
100
  Everything above is editable from the plugin's own settings panel inside the settings page.
100
101
  The panel also accepts API calls:
@@ -113,8 +114,15 @@ curl -X POST -H "Content-Type: application/json" \
113
114
  `settings` / `open-downloads` accept only requests from loopback with `confirm:true`.
114
115
  - **No guessed silent switches**: the publisher's declared `Silent` switches are preferred;
115
116
  generic defaults are used only when a manifest is unavailable.
116
- - **No silent elevation**: installers that need administrator rights show the normal UAC prompt.
117
- - **Version is verified after installing** and recorded in the log.
117
+ - **Elevation only for installs, and on demand**: install commands that need administrator rights
118
+ (MSI packages, installers writing into `Program Files`, the `winget upgrade` fallback) run in an
119
+ elevated copy through `Start-Process -Verb RunAs`. With UAC set to "always notify" you get one
120
+ prompt per install; with `ConsentPromptBehaviorAdmin = 0` it is completely silent. Reads
121
+ (`winget list` / `upgrade --include-unknown` / `--version`) never elevate.
122
+ - **A zero exit code is not success**: after installing, the plugin compares the installed version
123
+ against the target version and reports a failure when nothing actually changed — machine-scope
124
+ installers silently exit 0 without installing anything when they lack administrator rights
125
+ (Logitech G HUB used to be reported as "updated" that way).
118
126
  - **System components** (`.NET`, VC++ runtimes, …) are listed but not updated by default.
119
127
  - **Only its own directory is written**: state and logs live in `~/.dsh/dsh-app-updater/`.
120
128
 
package/README.md CHANGED
@@ -13,6 +13,9 @@
13
13
  dsh plugin --profile web add dsh-app-updater
14
14
  ```
15
15
 
16
+ > 刚发布的新版本可能被 pnpm 的 `minimumReleaseAge` 策略暂缓一段时间;想立刻装到指定版本就显式写版本号:
17
+ > `dsh plugin --profile web add dsh-app-updater@1.2.0`(细节见 [RELEASING.md](RELEASING.md))。
18
+
16
19
  不想走 npm,也可以用源码或发行包:
17
20
 
18
21
  ```bash
@@ -97,6 +100,7 @@ dsh plugin --profile web add https://github.com/19952730389/dsh-app-updater/rele
97
100
  | `hashFallbackUpgrade` | `true` | 下载失败时退回 `winget upgrade` 兜底。 |
98
101
  | `dryRun` | `false` | **演练模式**:只下载官网全量安装包、不执行安装。用来先看会装什么。 |
99
102
  | `matchSystemArch` | `true` | 按识别到的系统与芯片架构挑选安装包(Windows 传 `--architecture`;macOS 由 Homebrew 决定)。 |
103
+ | `autoElevate` | `true` | **自动提权安装**:需要管理员的安装包(MSI、写入 `Program Files`)改由管理员上下文执行;UAC 开启时每次安装会弹一次确认。 |
100
104
 
101
105
  用 API 改设置(等价于设置面板,适合脚本化):
102
106
 
@@ -113,10 +117,17 @@ curl -X POST -H "Content-Type: application/json" \
113
117
 
114
118
  ## 安全边界
115
119
 
116
- - **写操作全部要求本机来源 + 显式确认**:`scan` / `update` / `cancel` / `settings` / `open-downloads` 只接受 loopback 请求且必须带 `confirm:true`。
120
+ - **接入宿主 connection 准入**:每个请求先过 `ctx.connection.admit(request)` —— DSH 用来做
121
+ Host/Origin 信任校验与浏览器会话 / API 认证的那套边界(宿主自己的 `/api` 路由用的就是它),
122
+ 未通过的请求直接得到 `401`/`403`。宿主没有 `connection` 服务、或请求不带任何连接凭据且来自
123
+ 回环地址时,退回旧的「回环」规则,并在响应头标注 `X-DSH-Admission: loopback-legacy`。
124
+ - **写操作还要显式确认**:`scan` / `update` / `cancel` / `settings` / `open-downloads` 必须带 `confirm:true`。
117
125
  - **不猜测静默参数**:优先用发布方在清单里声明的 `Silent` 参数;清单缺失时才按安装包类型用保守的默认开关。
118
- - **不静默提权**:需要管理员权限的安装包会正常弹出 UAC,由你决定是否放行。
119
- - **安装后复核版本**:更新完会重新查询一次版本并记录,日志里能看到「更新完成,当前版本 X」。
126
+ - **需要管理员时自动提权**:机器级安装包(MSI、写入 `Program Files`)交给
127
+ `Start-Process -Verb RunAs` 的提权副本执行(可在设置里关掉「自动提权安装」);
128
+ UAC 为「始终通知」时每次安装会弹一次确认。
129
+ - **安装后复核版本**:用「安装前 / 安装后 / 目标」三个版本判断这次更新是否真的生效 ——
130
+ 退出码 0 不再等于成功(机器级安装包在没提权时会静默退出 0 却什么都没装)。
120
131
  - **不做系统组件更新**:`.NET` 运行时、VC++ 运行库等仍会列出来,但默认不作为一键更新的目标。
121
132
  - **只写自己的目录**:状态与日志写在 `~/.dsh/dsh-app-updater/`,不碰 profile 与宿主配置。
122
133
 
@@ -181,6 +192,15 @@ node test/harness.mjs --scan # 用假 cordis ctx + 真 HTTP 把宿主半身
181
192
 
182
193
  还可以在设置面板里关掉「按本机架构安装包」——关闭后把选择权交回 winget 自己(Homebrew 一直按本机架构)。
183
194
 
195
+ 机器级安装(MSI、写入 `Program Files`)需要管理员权限,否则 MSI 会返回 1603(MSI 日志里是
196
+ `Error 1925: You do not have sufficient privileges…`),部分安装器则可能**静默退出 0 却什么都没装**。
197
+ 因此安装类命令默认自动提权执行(设置里的「自动提权安装」,默认开启):安装包本体与
198
+ `winget upgrade` 兜底都交给 `Start-Process -Verb RunAs` 的提权副本,装完再按
199
+ 「安装前版本 / 安装后版本 / 目标版本」复核这次更新是否真的生效。
200
+ UAC 为「始终通知」时每次安装会弹一次确认;把 DSH 本身以管理员身份运行则不会再弹。
201
+ Edge 走它自带的更新服务:winget 目录里的 `Microsoft.Edge` 是企业版 MSI,与本机消费版的
202
+ 安装技术不匹配(`0x8A15008E`),winget 会直接拒绝。
203
+
184
204
  > macOS 的 Homebrew 路径是按 Homebrew 官方 CLI 行为实现的,识别与解析逻辑有单测覆盖;
185
205
  > 但本插件主要在 Windows 上开发,macOS 上的安装/更新没有真机验证过,遇到问题请开 Issue。
186
206
 
package/RELEASING.md CHANGED
@@ -56,6 +56,19 @@ gh release create v<version> dsh-app-updater.tgz \
56
56
  - dshopen.org → https://github.com/polang233/dsh-plugin-index/issues/new?template=plugin-add.yml
57
57
  - awesome-dsh-plugin → 向仓库提 PR,新增 `data/plugins/<owner>__<repo>.yml`(见其 contributing.md)
58
58
 
59
+ ### 刚发版时别人可能装到旧版本
60
+
61
+ pnpm 默认有 `minimumReleaseAge`(新发布的包先观察一段时间才参与解析)。profile 里若没写
62
+ `minimumReleaseAge: 0`,`dsh plugin add <包名>` 在发版后的一段时间内可能仍解析到上一个版本:
63
+
64
+ ```bash
65
+ # 立刻拿到刚发布的版本:显式指定版本号
66
+ dsh plugin --profile web add dsh-app-updater@<version>
67
+ ```
68
+
69
+ 这不是包的问题,是消费端的供应链策略;等宽限期过去后不带版本号也会解析到新版。
70
+ (排查时看 profile 的 `pnpm-workspace.yaml`:`minimumReleaseAge` 与 `minimumReleaseAgeExclude`。)
71
+
59
72
  ## 6. 本地开发时的桌面端安装
60
73
 
61
74
  Electron 托管的 `desktop` profile 不接受 `dsh plugin` 写入。做法是在
package/lib/client.js CHANGED
@@ -52,6 +52,11 @@ window.__ModuleLoader__.load({
52
52
  label: "按本机架构安装包",
53
53
  hint: "按当前系统与芯片(Windows ARM64 / x64、Apple Silicon / Intel)挑选对应安装包",
54
54
  },
55
+ {
56
+ key: "autoElevate",
57
+ label: "自动提权安装",
58
+ hint: "需要管理员的安装包(MSI、写入 Program Files)改由管理员上下文执行;UAC 开启时每次安装会弹一次确认",
59
+ },
55
60
  {
56
61
  key: "hashFallbackUpgrade",
57
62
  label: "下载失败时回退 winget",
package/lib/elevate.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Windows 提权执行。
3
+ *
4
+ * 为什么需要它:winget 目录里的「官网全量包」有一大半是**机器级**安装 —— MSI 要
5
+ * 写 HKLM / Program Files,安装器要往 Program Files 落文件。进程没有管理员令牌
6
+ * 时有两种失败方式:
7
+ * 1) MSI 直接失败(msiexec 返回 1603,MSI 日志里是 Error 1925「权限不足」);
8
+ * 2) 安装器**静默退出 0 却什么都没装**(实测 Logitech G HUB)。
9
+ * 第 2 种最坑:只看退出码就会误报「更新成功」,所以安装之后必须复核版本
10
+ * (见 engine.js 的 verifyInstalled)。
11
+ *
12
+ * Windows 的进程令牌创建后无法原地提升,因此这里把**一条命令**写进临时
13
+ * PowerShell 助手脚本,用 `Start-Process -Verb RunAs` 在提权副本里执行,再把
14
+ * 退出码与输出回传到文件后读回。两个脚本都写成 UTF-8 **带 BOM**:Windows
15
+ * PowerShell 5.1 读 .ps1 时若无 BOM 会按本地代码页解码,中文路径会乱。
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { runProcess } from "./proc.js";
24
+
25
+ const PS_PROBE_TIMEOUT_MS = 20000;
26
+
27
+ /** Windows 取消提权的退出码(ERROR_CANCELLED)。 */
28
+ export const ELEVATION_CANCELLED = 1223;
29
+
30
+ /** PowerShell 单引号字面量:内部的 `'` 要写成 `''`。 */
31
+ export function psQuote(value) {
32
+ return `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
33
+ }
34
+
35
+ /** Windows PowerShell 5.1 的绝对路径。 */
36
+ export function powershellPath(env = process.env) {
37
+ const root = env.SystemRoot || env.windir || "C:\\Windows";
38
+ return join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
39
+ }
40
+
41
+ /** 本机是否有可用的 PowerShell(没有它就无法提权)。 */
42
+ export function elevationAvailable(env = process.env) {
43
+ try {
44
+ return existsSync(powershellPath(env));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ let cachedElevated = null;
51
+
52
+ /** 清掉「是否已提权」的缓存(单元测试与提权后复核用)。 */
53
+ export function resetElevationCache() {
54
+ cachedElevated = null;
55
+ }
56
+
57
+ /**
58
+ * 当前进程是否在管理员上下文里。
59
+ * 非 Windows 平台按 uid==0 判断(macOS 的 brew 永远不需要提权)。
60
+ */
61
+ export async function isElevated(opts = {}) {
62
+ if (cachedElevated !== null && !opts.refresh) return cachedElevated;
63
+ if (process.platform !== "win32") {
64
+ cachedElevated = typeof process.getuid === "function" ? process.getuid() === 0 : true;
65
+ return cachedElevated;
66
+ }
67
+ if (!elevationAvailable()) {
68
+ cachedElevated = false;
69
+ return cachedElevated;
70
+ }
71
+ const res = await runProcess(
72
+ powershellPath(),
73
+ [
74
+ "-NoProfile",
75
+ "-NonInteractive",
76
+ "-ExecutionPolicy",
77
+ "Bypass",
78
+ "-Command",
79
+ "[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
80
+ ],
81
+ { timeoutMs: PS_PROBE_TIMEOUT_MS, signal: opts.signal }
82
+ );
83
+ cachedElevated = res.ok && /true/i.test(res.stdout);
84
+ return cachedElevated;
85
+ }
86
+
87
+ function stripBom(text) {
88
+ return String(text || "").replace(/^\ufeff/, "");
89
+ }
90
+
91
+ /**
92
+ * 在提权副本里执行一条命令。
93
+ *
94
+ * 返回结构与 proc.js 的 runProcess 一致(外加 cancelled),因此调用方可以
95
+ * 无差别地把它当成「另一个 runProcess」。
96
+ *
97
+ * @param {string} exe 绝对路径
98
+ * @param {string[]} args 参数数组
99
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
100
+ * @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, timedOut:boolean, spawnError:string|null, cancelled:boolean}>}
101
+ */
102
+ export async function runElevated(exe, args, opts = {}) {
103
+ const timeoutMs = opts.timeoutMs || 60 * 60 * 1000;
104
+ const fail = (spawnError) => ({
105
+ ok: false,
106
+ code: null,
107
+ stdout: "",
108
+ stderr: "",
109
+ timedOut: false,
110
+ spawnError,
111
+ cancelled: false,
112
+ });
113
+
114
+ if (process.platform !== "win32") return fail("非 Windows 平台不支持提权执行");
115
+ if (!elevationAvailable()) return fail("未找到 powershell.exe,无法提权执行");
116
+
117
+ const ps = powershellPath();
118
+ const dir = await mkdtemp(join(tmpdir(), "dsh-app-updater-elev-"));
119
+ const outFile = join(dir, "out.txt");
120
+ const codeFile = join(dir, "code.txt");
121
+ const helperFile = join(dir, "helper.ps1");
122
+ const launchFile = join(dir, "launch.ps1");
123
+
124
+ const helper = [
125
+ "$ErrorActionPreference = 'Continue'",
126
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
127
+ "$OutputEncoding = [System.Text.Encoding]::UTF8",
128
+ `$exe = ${psQuote(exe)}`,
129
+ `$argv = @(${(args || []).map(psQuote).join(", ")})`,
130
+ `& $exe @argv 2>&1 | Out-File -FilePath ${psQuote(outFile)} -Encoding UTF8`,
131
+ "$code = $LASTEXITCODE",
132
+ "if ($null -eq $code) { $code = 0 }",
133
+ `Set-Content -Path ${psQuote(codeFile)} -Value $code -Encoding ASCII`,
134
+ "exit $code",
135
+ "",
136
+ ].join("\r\n");
137
+
138
+ // 启动器只负责「把助手脚本以管理员身份跑起来并把退出码带回来」。
139
+ const launch = [
140
+ "$ErrorActionPreference = 'Stop'",
141
+ "try {",
142
+ ` $p = Start-Process -FilePath ${psQuote(ps)} -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File', ${psQuote(helperFile)}) -Verb RunAs -WindowStyle Hidden -Wait -PassThru`,
143
+ `} catch { exit ${ELEVATION_CANCELLED} }`,
144
+ `if ($null -eq $p) { exit ${ELEVATION_CANCELLED} }`,
145
+ "exit $p.ExitCode",
146
+ "",
147
+ ].join("\r\n");
148
+
149
+ try {
150
+ await writeFile(helperFile, `\ufeff${helper}`, "utf8");
151
+ await writeFile(launchFile, `\ufeff${launch}`, "utf8");
152
+ } catch (err) {
153
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
154
+ return fail(String((err && err.message) || err));
155
+ }
156
+
157
+ const res = await runProcess(
158
+ ps,
159
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launchFile],
160
+ { timeoutMs, signal: opts.signal }
161
+ );
162
+
163
+ let stdout = "";
164
+ try {
165
+ stdout = stripBom(await readFile(outFile, "utf8"));
166
+ } catch {
167
+ // 安装器没有产出任何输出:正常(GUI 安装包),失败原因由退出码与 MSI 日志给
168
+ }
169
+
170
+ let code = res.code;
171
+ let reported = false;
172
+ try {
173
+ const raw = stripBom(await readFile(codeFile, "utf8")).trim();
174
+ if (/^-?\d+$/.test(raw)) {
175
+ code = Number(raw);
176
+ reported = true;
177
+ }
178
+ } catch {
179
+ // 提权副本没能跑到写退出码那一步
180
+ }
181
+
182
+ // 超时时提权副本可能还在跑(安装器),保留临时目录不删,避免动到正在执行的脚本
183
+ if (!res.timedOut) {
184
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
185
+ }
186
+
187
+ const cancelled = res.code === ELEVATION_CANCELLED || code === ELEVATION_CANCELLED;
188
+ const spawnError = res.spawnError
189
+ ? res.spawnError
190
+ : cancelled
191
+ ? "提权被拒绝(UAC 被取消)"
192
+ : !reported && !res.timedOut && code !== 0
193
+ ? "提权副本未能报告退出码(可能被 UAC 拒绝)"
194
+ : null;
195
+
196
+ return {
197
+ ok: !res.timedOut && !spawnError && code === 0,
198
+ code,
199
+ stdout,
200
+ stderr: "",
201
+ timedOut: res.timedOut,
202
+ spawnError,
203
+ cancelled,
204
+ };
205
+ }
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 });
package/lib/index.js CHANGED
@@ -79,12 +79,62 @@ function readJsonBody(req) {
79
79
  });
80
80
  }
81
81
 
82
- /** 写操作闸门:loopback + confirm:true。 */
83
- async function writeGate(req, res) {
84
- if (!isLoopback(req)) {
85
- json(res, 403, { ok: false, error: "只接受本机请求(loopback)" });
86
- return null;
82
+ /** 请求是否携带了 DSH 连接凭据(浏览器会话 Cookie 或 API Cookie)。 */
83
+ export function hasConnectionCredentials(req) {
84
+ const h = (req && req.headers) || {};
85
+ return Boolean(h.cookie || h.authorization);
86
+ }
87
+
88
+ /**
89
+ * connection 准入(宿主信任边界)。
90
+ *
91
+ * DSH 的 `connection` 服务负责插件 HTTP 路由的信任校验:`admit(request)` 先做
92
+ * Host/Origin 校验,再做浏览器会话 / API 认证,返回本次调用对应的 Peer,或用
93
+ * `401`(未认证)/`403`(不可信来源)拒绝 —— 宿主自己的 `/api` 路由用的就是它。
94
+ *
95
+ * 退回策略(只有一条,且完全不涉及凭据):宿主没有 `connection` 服务,
96
+ * 或者请求**没有携带任何连接凭据**、来自回环地址时,按旧的「回环」规则放行,
97
+ * 并在响应头标注 `X-DSH-Admission: loopback-legacy`。
98
+ * 带凭据的请求一律以 admission 的结果为准;非回环请求一律拒绝。
99
+ *
100
+ * @returns {boolean} 已放行 true;已写出拒绝响应 false
101
+ */
102
+ export function admissionGate(req, res, connection) {
103
+ const loopback = isLoopback(req);
104
+ if (connection && typeof connection.admit === "function") {
105
+ let admission;
106
+ try {
107
+ admission = connection.admit(req);
108
+ } catch (err) {
109
+ json(res, 500, { ok: false, error: `连接准入失败:${String((err && err.message) || err)}` });
110
+ return false;
111
+ }
112
+ if (!admission || typeof admission.rejection !== "number") return true;
113
+ if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req)) {
114
+ // 宿主自己的载体(Electron 经 IPC 桥接的 fetch)与本机脚本都不带 Cookie,
115
+ // 没有可核验的凭据,只能按回环信任放行 —— 与接入准入之前的行为一致。
116
+ if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "loopback-legacy");
117
+ return true;
118
+ }
119
+ res.writeHead(admission.rejection, { "Content-Type": "text/plain; charset=utf-8" });
120
+ res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
121
+ return false;
122
+ }
123
+ if (!loopback) {
124
+ json(res, 403, { ok: false, error: "只接受本机请求(loopback):当前宿主没有可用的 connection 准入服务" });
125
+ return false;
87
126
  }
127
+ if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "loopback-legacy");
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * 写操作闸门:`confirm:true`。
133
+ *
134
+ * 来源校验已经由 `register()` 里的 connection 准入做过(见 admissionGate),
135
+ * 这里只负责「这次写入是用户显式确认过的」。
136
+ */
137
+ async function writeGate(req, res) {
88
138
  const body = await readJsonBody(req);
89
139
  if (body.tooLarge) {
90
140
  json(res, 413, { ok: false, error: "请求体过大" });
@@ -247,6 +297,9 @@ async function startUpdate(ids) {
247
297
  try {
248
298
  await runUpdates({
249
299
  ids: list,
300
+ // 把界面刚扫描出来的「已装版本 / 目标版本」一并带过去:安装后的复核要用它
301
+ // 判断这次更新到底有没有真的生效(只看退出码会把「静默退出 0 却什么都没装」误报成功)
302
+ plan: (store.state.apps.items || []).filter((it) => list.includes(it.id)),
250
303
  settings: store.state.settings,
251
304
  log: (level, text) => store.log(level, text),
252
305
  signal: abort.signal,
@@ -297,10 +350,33 @@ export default {
297
350
 
298
351
  ctx.inject(["webServer"], (webCtx) => {
299
352
  const webServer = webCtx.webServer;
353
+
354
+ // connection 准入:宿主提供的信任边界(Host/Origin 校验 + 浏览器/API 认证)。
355
+ // 它在宿主没有 Web 载体时不存在,此时 admissionGate 退回旧的「回环」检查。
356
+ let connection = null;
357
+ ctx.inject(["connection"], (connCtx) => {
358
+ connection = connCtx.connection;
359
+ return () => {
360
+ connection = null;
361
+ };
362
+ });
300
363
  const routes = [];
301
364
 
365
+ /**
366
+ * 注册一条路由:请求先过 connection 准入(见 admissionGate),再进业务处理。
367
+ * 写接口另有 `confirm:true` 闸门(writeGate)。
368
+ */
302
369
  const register = (path, handler) => {
303
- routes.push(webServer.register({ kind: "exact", path: `${PREFIX}${path}`, handler }));
370
+ routes.push(
371
+ webServer.register({
372
+ kind: "exact",
373
+ path: `${PREFIX}${path}`,
374
+ handler: (req, res) => {
375
+ if (!admissionGate(req, res, connection)) return;
376
+ return handler(req, res);
377
+ },
378
+ })
379
+ );
304
380
  };
305
381
 
306
382
  register("/state.json", async (req, res) => {
package/lib/state.js CHANGED
@@ -32,6 +32,8 @@ export function defaultSettings() {
32
32
  dryRun: false,
33
33
  // 按本机架构(Windows ARM64 / x64、Apple Silicon / Intel)挑选安装包
34
34
  matchSystemArch: true,
35
+ // 需要管理员的安装包(MSI / 写入 Program Files)改用管理员上下文执行
36
+ autoElevate: true,
35
37
  };
36
38
  }
37
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-app-updater",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "App Updater for DeepSeek Harness: detect every installed app that has a newer version and update it with one click by downloading the vendor's full installer from its official website and installing it silently — without launching the app. Windows (winget) and macOS (Homebrew), architecture-aware (ARM64 / x64). · 检测本机所有已安装应用是否有新版本,并从各自官网下载全量安装包一键静默更新(不需要打开被检测的软件);自动识别 Windows / macOS 与 ARM64 / x64 架构,按架构下载对应安装包。",
6
6
  "license": "MIT",
package/test/render.mjs CHANGED
@@ -359,7 +359,7 @@ tree = rt.render();
359
359
  group("设置面板与列宽手柄");
360
360
  ok(byClass(tree, "appupd-panel").length === 1, "渲染出插件设置面板");
361
361
  const switches = byClass(tree, "appupd-switch");
362
- ok(switches.length === 6, "渲染出 6 个设置开关", switches.length);
362
+ ok(switches.length === 7, "渲染出 7 个设置开关(新增自动提权)", switches.length);
363
363
  ok(byClass(tree, "appupd-grip").length === 4, "渲染出 4 个列宽拖拽手柄", byClass(tree, "appupd-grip").length);
364
364
  const ths = walk(tree).filter((n) => n.type === "th");
365
365
  ok(ths.length === 6, "表头 6 列", ths.length);
package/test/unit.mjs CHANGED
@@ -18,7 +18,9 @@ import {
18
18
  sliceByWidth,
19
19
  } from "../lib/winget.js";
20
20
  import { compareVersions, isNewer, looksLikeVersion, splitVersion } from "../lib/version.js";
21
- import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew } from "../lib/engine.js";
21
+ import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew, isArchNotFoundError, explainInstallerExitCode, evaluateUpdateResult, findEdgeUpdate, updateHintFor } from "../lib/engine.js";
22
+ import { psQuote, ELEVATION_CANCELLED } from "../lib/elevate.js";
23
+ import { admissionGate, hasConnectionCredentials } from "../lib/index.js";
22
24
  import {
23
25
  detectEnvironment,
24
26
  describeEnvironment,
@@ -244,6 +246,102 @@ ok(outdated.get("visual-studio-code") === "1.95.0" && outdated.get("iterm2") ===
244
246
  ok(parseBrewOutdated("not json").size === 0, "坏 JSON 不抛错");
245
247
  ok(findBrew({ HOMEBREW_PREFIX: "/nonexistent-prefix-for-test" }) === null, "找不到 brew 时返回 null");
246
248
 
249
+ /* ---------------- 提权与安装复核 ---------------- */
250
+ group("提权执行(PowerShell 助手)");
251
+ ok(psQuote("C:\\Program Files\\a.exe") === "'C:\\Program Files\\a.exe'", "psQuote 保留 Windows 路径");
252
+ ok(psQuote("it's") === "'it''s'", "psQuote 转义单引号");
253
+ ok(psQuote(null) === "''", "psQuote 处理 null");
254
+ ok(ELEVATION_CANCELLED === 1223, "UAC 被取消的退出码是 1223");
255
+
256
+ group("架构缺失错误识别(中英文都要认)");
257
+ ok(isArchNotFoundError("找不到适用的安装程序;有关详细信息,请参阅日志。"), "中文「找不到适用」");
258
+ ok(isArchNotFoundError("No applicable installer found"), "英文 No applicable installer");
259
+ ok(!isArchNotFoundError("No package found matching input criteria"), "「找不到该包」不算架构问题");
260
+ ok(!isArchNotFoundError(""), "空输出不算");
261
+ ok(!isArchNotFoundError(null), "null 不算");
262
+
263
+ group("安装后复核:退出码 0 不等于成功");
264
+ ok(evaluateUpdateResult({ before: "2026.6.981214", after: "2026.6.981214", target: "2026.7.992920" }).changed === false, "G HUB:退出码 0、版本没变 → 判为失败");
265
+ ok(evaluateUpdateResult({ before: "2026.6.981214", after: "2026.7.992920", target: "2026.7.992920" }).changed === true, "达到目标版本 → 成功");
266
+ ok(evaluateUpdateResult({ before: "2026.6.981214", after: "2026.8.0", target: "2026.7.992920" }).changed === true, "超过目标版本 → 成功");
267
+ ok(evaluateUpdateResult({ before: "Unknown", after: "Unknown", target: "7.5.0.0" }).unverifiable === true, "虎牙:读不到版本 → 无法复核(不能算失败)");
268
+ ok(evaluateUpdateResult({ before: "1.0", after: "", target: "2.0" }).unverifiable === true, "空版本 → 无法复核");
269
+ ok(evaluateUpdateResult({ before: "1.0.0", after: "1.2.0", target: "" }).changed === true, "目标未知但版本变新 → 成功");
270
+ ok(evaluateUpdateResult({ before: "1.2.0", after: "1.2.0", target: "" }).changed === false, "目标未知且没变 → 失败");
271
+
272
+ group("安装器退出码 → 人话");
273
+ ok(explainInstallerExitCode(1603, "").includes("1603"), "1603 给出可读解释");
274
+ ok(explainInstallerExitCode(1603, "first line\nsecond line") === "first line", "有输出时优先用输出的首行");
275
+ ok(explainInstallerExitCode(9999, "") === "安装程序退出码 9999", "未知退出码原样透出");
276
+
277
+ group("Edge 自带更新服务定位");
278
+ const edgeUpdate = findEdgeUpdate();
279
+ ok(edgeUpdate === null || /MicrosoftEdgeUpdate\.exe$/i.test(edgeUpdate), "返回绝对路径或 null");
280
+ ok(updateHintFor("Logitech.GHUB").includes("启动 G HUB"), "G HUB 给出「启动应用才会应用更新」的提示");
281
+ ok(updateHintFor("microsoft.edge").length > 0, "Edge 有提示");
282
+ ok(updateHintFor("Some.Other") === "" && updateHintFor(null) === "", "未知应用没有提示");
283
+
284
+ /* ---------------- connection 准入 ---------------- */
285
+ function fakeRes() {
286
+ const res = { statusCode: 0, headers: {}, body: "" };
287
+ res.setHeader = (k, v) => {
288
+ res.headers[k] = v;
289
+ };
290
+ res.writeHead = (code, headers) => {
291
+ res.statusCode = code;
292
+ Object.assign(res.headers, headers || {});
293
+ };
294
+ res.end = (body) => {
295
+ res.body = String(body == null ? "" : body);
296
+ };
297
+ return res;
298
+ }
299
+ function fakeReq({ loopback = true, headers = {} } = {}) {
300
+ return { socket: { remoteAddress: loopback ? "127.0.0.1" : "10.0.0.5" }, headers };
301
+ }
302
+
303
+ group("connection 准入(市场审核要求)");
304
+ ok(hasConnectionCredentials({ headers: { cookie: "dsh=x" } }) === true, "识别浏览器会话 Cookie");
305
+ ok(hasConnectionCredentials({ headers: { authorization: "Bearer x" } }) === true, "识别 API Cookie/Authorization");
306
+ ok(hasConnectionCredentials({ headers: {} }) === false, "没有凭据时返回 false");
307
+ ok(admissionGate(fakeReq(), fakeRes(), null) === true, "宿主没有 connection 服务时,回环请求放行");
308
+ {
309
+ const res = fakeRes();
310
+ ok(admissionGate(fakeReq({ loopback: false }), res, null) === false && res.statusCode === 403, "没有 connection 服务时,非回环请求 403");
311
+ }
312
+ {
313
+ const res = fakeRes();
314
+ const connection = { admit: () => ({ peer: { id: "peer-1" } }) };
315
+ ok(admissionGate(fakeReq(), res, connection) === true && res.statusCode === 0, "准入通过 → 放行且不写响应");
316
+ }
317
+ {
318
+ const res = fakeRes();
319
+ const connection = { admit: () => ({ rejection: 403 }) };
320
+ ok(admissionGate(fakeReq({ loopback: false }), res, connection) === false && res.statusCode === 403, "准入拒绝 403(不可信来源)→ 403");
321
+ }
322
+ {
323
+ const res = fakeRes();
324
+ const connection = { admit: () => ({ rejection: 401 }) };
325
+ ok(admissionGate(fakeReq({ headers: { cookie: "dsh=x" } }), res, connection) === false && res.statusCode === 401, "带凭据但未认证 → 401");
326
+ }
327
+ {
328
+ const res = fakeRes();
329
+ const connection = { admit: () => ({ rejection: 401 }) };
330
+ ok(
331
+ admissionGate(fakeReq(), res, connection) === true && res.headers["X-DSH-Admission"] === "loopback-legacy",
332
+ "无凭据的回环请求退回旧规则并标注响应头"
333
+ );
334
+ }
335
+ {
336
+ const res = fakeRes();
337
+ const connection = {
338
+ admit: () => {
339
+ throw new Error("carrier unavailable");
340
+ },
341
+ };
342
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 500, "准入抛错 → 500 而不是静默放行");
343
+ }
344
+
247
345
  /* ---------------- 结果 ---------------- */
248
346
  console.log(`\n${failures === 0 ? "全部通过" : "存在失败"}:${checks - failures}/${checks}`);
249
347
  process.exit(failures === 0 ? 0 : 1);