dsh-app-updater 1.3.4 → 1.3.5
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 +72 -0
- package/README.en.md +13 -1
- package/README.md +13 -4
- package/lib/elevate.js +56 -6
- package/lib/engine.js +128 -20
- package/lib/index.js +60 -8
- package/lib/registry.js +18 -7
- package/lib/version.js +43 -4
- package/lib/winget.js +6 -3
- package/package.json +1 -1
- package/test/unit.mjs +95 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.3.5] — 2026-11-02
|
|
6
|
+
|
|
7
|
+
两轮对抗式复查的合并收口(1.3.4 之后),修掉 9 个真实缺陷、补上 1 处文档与实现不一致的承诺。
|
|
8
|
+
这一版没有新功能 —— 全是「判断错误 / 静默失败 / 条件触发的安全面」类问题。
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- **尾缀字典序把 build 号判反**(`lib/version.js`)。数字段一致后,尾缀(`-build10`、
|
|
13
|
+
`-r100`、`-beta2`、Java 的 `8u202` 这类)原来是**整串字典序**比较:`-build9` > `-build10`、
|
|
14
|
+
`8u99` > `8u202` —— 位数一变顺序就翻转。对插件来说这是静默漏报:已装 `1.2.3-build9`、
|
|
15
|
+
有 `1.2.3-build10`,`isNewer()` 返回 false → 用户看到「已是最新」;复核路径还会把成功
|
|
16
|
+
安装误判「没有变化」。现在尾缀按**自然序**比较(数字块按数值、字母块按字典序,
|
|
17
|
+
`-beta2 < -beta10`),保留原有语义:正式版 > 任何预发布尾缀。
|
|
18
|
+
- **Windows 扫描不检查取消/超时,半截输出伪装成「0 个可更新」**(`lib/engine.js scanApps`)。
|
|
19
|
+
取消或 240s 超时杀掉 `winget list` 后,半截 stdout 被解析成残缺清单;
|
|
20
|
+
`if (upRes.ok || upRes.stdout)` 又把超时残留字符串真值当成功 → `hasUpdate` 全 false →
|
|
21
|
+
整表覆盖上一次完整结果。macOS 路径 1.3.4 修过同款,这次补上 Windows 侧:
|
|
22
|
+
取消一律向上抛(不算故障、不写红横幅),非零退出/超时不再解析,降级注册表源并如实
|
|
23
|
+
警告「本次清单可能不完整」。
|
|
24
|
+
- **注册表读取会被一条无关的 PowerShell 警告整体误伤**(`lib/registry.js`)。stderr 原来与
|
|
25
|
+
stdout 共用同一缓冲数组:任何一条执行策略提示或 .NET 横幅插进 JSON 中部 → 整体解析失败
|
|
26
|
+
→ 注册表源全量丢弃,用户以为这台机器读不到已装应用。winget 侧同病(`lib/winget.js`):
|
|
27
|
+
stderr 混进定宽表还可能伪造数据行。现在两个进程都分桶收集,`stderr` 字段如实传给错误分类。
|
|
28
|
+
- **哈希校验:文档承诺 ≠ 实际行为 → 现在真的做了**(`lib/engine.js`)。README 写「下载时按清单
|
|
29
|
+
核对 SHA-256」——winget 自己确实会核对(我们从不传 `--skip-hash-check`),但**执行前的那个文件**
|
|
30
|
+
此前没有任何本地校验。而这个文件紧接着会以**管理员**身份静默执行。1.3.5 起:
|
|
31
|
+
- `parseInstallerManifest` 按 Installer 块配对收集 `Architecture → InstallerSha256`
|
|
32
|
+
(兼容 `- Architecture:` 列表首项写法);
|
|
33
|
+
- `verifyInstallerHash` 流式(`createReadStream`,不整读几百 MB 的包)复核实际哈希;
|
|
34
|
+
- 配对策略:先按本机架构键,再用文件名别名(`64-bit`/`x64`/`arm64`/`aarch64`…),
|
|
35
|
+
单哈希清单直接用;**多架构配对不出时宁可跳过并记 info**,绝不拿别架构哈希把正确文件判成篡改;
|
|
36
|
+
- 不一致 → **拒绝执行**,报「期望/实际」两个哈希,照旧走 `winget upgrade` 兜底;
|
|
37
|
+
- 更新结果新增 `hash: verified|skipped|mismatch`。
|
|
38
|
+
- **兼容模式(legacy loopback)可被任意网页 CSRF 驱动提权安装**(`lib/index.js`)。开启
|
|
39
|
+
`allowLegacyLoopback` 后,第三方网页的 `fetch("http://127.0.0.1:…/update",{mode:"no-cors"})`
|
|
40
|
+
天然不带 Cookie → 过「无凭据才豁免」那条规则 → 无认证触发「下载任意包 + UAC 提权静默安装」。
|
|
41
|
+
现在豁免路径加来源围栏:`Origin`/`Referer` 存在且与 `Host` 不同源、或
|
|
42
|
+
`Sec-Fetch-Site: cross-site`,一律 403(浏览器禁止脚本伪造这些头,判定可靠;本机脚本与
|
|
43
|
+
IPC 桥不带这些头,不受影响)。
|
|
44
|
+
- **取消/超时杀不掉提权安装树**(`lib/elevate.js`)。旧实现 Node 侧只能杀**启动器**;
|
|
45
|
+
提权副本与安装器在独立进程树里继续跑,而队列已排到下一个应用(并发双装 + 幽灵安装)。
|
|
46
|
+
现在助手脚本(本身就是提权进程)自己盯 `cancel.flag`(500ms 轮询),看到就
|
|
47
|
+
`taskkill /T /F` 杀整棵安装器树再报告退出码;读不到退出码时按失败处理,绝不伪造 0。
|
|
48
|
+
- **「取消后立刻重试」的竞态**(`lib/index.js`)。旧任务 finally 无条件清 `control` 槽位,
|
|
49
|
+
会把新任务句柄清成 null → `/cancel` 声称成功但新任务不可取消,旧半截结果还会覆盖新结果。
|
|
50
|
+
现在 finally 做所有权检查(`control.scanAbort === abort` 才清),被接管的任务结果作废。
|
|
51
|
+
- **macOS 更新复核失败仍报「更新完成」**(`lib/engine.js runMacosUpdates`)。
|
|
52
|
+
`brew list` 读空时此前直接 `log success` 且 `entry.ok=true` 无标注;现在与 Windows 准则
|
|
53
|
+
一致:读不到版本 → note 标「未复核:brew list 读不到更新后的版本」+ warn 日志;
|
|
54
|
+
取消/超时优先判错,不给误导性原因。
|
|
55
|
+
- **注册表 PowerShell 枚举没有取消通道**(`lib/registry.js`)。扫描取消后仍跑满 60s;
|
|
56
|
+
现在 `spawn({ signal })` 直通,取消立刻杀。
|
|
57
|
+
- **`normalizeName` 的 `\b版本\b` 是死分支**(`lib/registry.js`)。JS `\b` 只认 ASCII 边界,
|
|
58
|
+
「版」「本」都是非词字符 → 永不命中 → 「XXX 版本 1.2.3」与 winget 的「XXX」认不成同一条,
|
|
59
|
+
同一软件重复列出。改为字面匹配 `版本`。
|
|
60
|
+
- **中文 winget 的落盘行匹配不到**(`lib/engine.js downloadInstaller`)。只认英文
|
|
61
|
+
`Installer downloaded:`,中文系统的「安装程序已下载:」落到不可靠的目录 diff,失手时报
|
|
62
|
+
「没找到安装包」→ 触发兜底**把同一个包再下载一遍**。正则改为中英双语(与
|
|
63
|
+
`classifyWingetError` 一致)。
|
|
64
|
+
|
|
65
|
+
### 文档
|
|
66
|
+
|
|
67
|
+
- README / README.en 的执行流程表补充「执行前本地复核哈希(1.3.5 起)」,使承诺与实现一致。
|
|
68
|
+
|
|
69
|
+
### 测试
|
|
70
|
+
|
|
71
|
+
- `node test/unit.mjs` **184/184**(+31:自然序 8、清单哈希配对 4、哈希端到端 3、
|
|
72
|
+
来源围栏 9、归一化 2、双语匹配 3、取消检查 2)、`node test/render.mjs` **51/51**、
|
|
73
|
+
`node test/deny.mjs` **12/12**。
|
|
74
|
+
- 哈希端到端用 `mkdtemp` 临时目录真算真比:一致 → 通过;不一致 → 拒绝且错误里带两个哈希;
|
|
75
|
+
清单缺哈希 → `skipped` 而不是失败。
|
|
76
|
+
|
|
5
77
|
## [1.3.4] — 2026-11-01
|
|
6
78
|
|
|
7
79
|
1.3.3 发布后对着同样几台真实机器做了一轮对抗式复查,修掉 5 个「1.3.3 自己引入或没修干净」的问题。
|
package/README.en.md
CHANGED
|
@@ -44,7 +44,7 @@ profile's `node_modules` and add it to `dsh.profile.bundles` manually.
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| 1. List installed apps | `winget list` (apps known to the winget catalog plus Store/MSIX packages) **and** the three Windows registry `Uninstall` keys (apps the catalog does not know). Read-only: no application is ever started. |
|
|
46
46
|
| 2. Decide whether an update exists | `winget upgrade --include-unknown` gives "installed version → available version" from the official winget catalog, merged into the inventory by package id. |
|
|
47
|
-
| 3. One-click update | `winget download` fetches the **vendor's full installer** from the URL recorded in the winget manifest (SHA-256 verified), the downloaded manifest supplies the publisher's own `InstallerSwitches.Silent`, the installer runs silently, and the resulting version is re-read with `winget list --id`. |
|
|
47
|
+
| 3. One-click update | `winget download` fetches the **vendor's full installer** from the URL recorded in the winget manifest (SHA-256 verified by winget), the plugin **re-checks the hash locally before executing the file** (since 1.3.5 — the installer is about to run, possibly elevated, so a swapped or corrupted download is refused and the fallback path is taken), the downloaded manifest supplies the publisher's own `InstallerSwitches.Silent`, the installer runs silently, and the resulting version is re-read with `winget list --id`. |
|
|
48
48
|
| 4. Fallback | If the vendor re-signed the installer (hash no longer matches the catalog) or the manifest forbids direct download, it falls back to `winget upgrade --id … --exact --silent` — still the full installer from the vendor, with winget doing the fetching and installing. |
|
|
49
49
|
|
|
50
50
|
Those download URLs really are the vendors' own: `github.com/git-for-windows/…`,
|
|
@@ -128,6 +128,18 @@ curl -X POST -H "Content-Type: application/json" \
|
|
|
128
128
|
requests carry `X-DSH-Admission: loopback-legacy-optin` and `X-DSH-Admission-Warning`, the
|
|
129
129
|
`admission` field in `state.json` / `health.json` reports `mode: "loopback-legacy-optin"`, the
|
|
130
130
|
settings page shows a red banner, and a `warn` line is written to the log.
|
|
131
|
+
- **Cross-site fence on the legacy exemption (since 1.3.5)**: even when the opt-in is enabled, a
|
|
132
|
+
loopback request is rejected (`403`) when it looks browser-originated and cross-site — an
|
|
133
|
+
`Origin` / `Referer` whose host differs from the request `Host`, or `Sec-Fetch-Site: cross-site`.
|
|
134
|
+
Browsers forbid scripts from forging these headers, so the check is reliable: an arbitrary web
|
|
135
|
+
page can no longer drive "download + elevated silent install" with a cookie-less `fetch(…/update)`.
|
|
136
|
+
Local scripts and the host's IPC bridge (which send none of these headers) are unaffected.
|
|
137
|
+
- **The installer hash is re-checked locally before running (since 1.3.5)**: after `winget download`,
|
|
138
|
+
the plugin recomputes SHA-256 over the on-disk file against the manifest's `InstallerSha256`
|
|
139
|
+
before executing it (streaming, so a 500 MB package is not read into memory). That file is about
|
|
140
|
+
to run, possibly elevated, so a swapped or corrupted download is refused and the fallback taken.
|
|
141
|
+
When the manifest has no hash pairable to this machine's architecture, the check is skipped and
|
|
142
|
+
logged — never judged against another architecture's hash.
|
|
131
143
|
- **Every write still requires explicit confirmation**: `scan` / `update` / `cancel` /
|
|
132
144
|
`settings` / `open-downloads` require `confirm:true`.
|
|
133
145
|
- **No guessed silent switches**: the publisher's declared `Silent` switches are preferred;
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ dsh plugin --profile web add https://github.com/19952730389/dsh-app-updater/rele
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| ① 列出已装应用 | `winget list`(覆盖 winget 目录收录的软件 + 商店/MSIX 包)+ Windows 注册表三处 `Uninstall` 键(覆盖 winget 未收录的软件)。全程只读文件与注册表,不启动任何应用。 |
|
|
46
46
|
| ② 判断有没有新版本 | `winget upgrade --include-unknown`:由 winget 官方软件目录给出「已装版本 → 可升级版本」,并与 ①的清单按 Id 合并。 |
|
|
47
|
-
| ③ 一键更新 | `winget download` 把**发布方官网的完整安装包**下载到你指定的目录(下载时按清单核对 SHA-256
|
|
47
|
+
| ③ 一键更新 | `winget download` 把**发布方官网的完整安装包**下载到你指定的目录(下载时按清单核对 SHA-256),**执行前插件再本地复核一次哈希**(1.3.5 起:安装包紧跟着要以管理员身份执行,中途被替换或损坏必须拦住;不一致就不执行、改用兜底),解析随包落盘的 winget 清单得到发布方自己的静默参数,再执行静默安装;安装完成后用 `winget list --id` 复核版本号。 |
|
|
48
48
|
| ④ 失败兜底 | 若官网安装包被发布方重新签发导致哈希与目录记录不一致、或清单禁止直接下载,自动退回 `winget upgrade --id <id> --exact --silent`(同样是从官网拉取完整安装包,只是下载与安装过程交给 winget)。 |
|
|
49
49
|
|
|
50
50
|
**「从对应官网下载」是真的官网**:安装包地址来自 winget 清单里的 `InstallerUrl`,例如
|
|
@@ -154,10 +154,18 @@ curl -X POST -H "Content-Type: application/json" \
|
|
|
154
154
|
- 或启动 DSH 前设置环境变量 `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`。
|
|
155
155
|
|
|
156
156
|
开启后:放行的回环请求带 `X-DSH-Admission: loopback-legacy-optin` 与
|
|
157
|
-
`X-DSH-Admission-Warning`,`state.json` / `health.json` 的 `admission` 字段会标明
|
|
158
|
-
|
|
157
|
+
`X-DSH-Admission-Warning`,`state.json` / `health.json` 的 `admission` 字段会标明 `mode: "loopback-legacy-optin"`,设置页顶部也会出现红色提示条;日志里记一条 `warn`。
|
|
158
|
+
- **兼容模式的来源围栏(1.3.5 起)**:豁免路径拒绝「跨站浏览器请求」——`Origin` / `Referer`
|
|
159
|
+
存在且与本次请求的 `Host` 不同源,或 `Sec-Fetch-Site: cross-site`,一律 `403`。
|
|
160
|
+
浏览器禁止脚本伪造这几个头(forbidden header),所以判定可靠:任意第三方网页的
|
|
161
|
+
无 Cookie `fetch(…/update)` 不再可能驱动「下载 + 提权安装」;本机脚本与宿主 IPC 桥
|
|
162
|
+
(不带这些头)不受影响。
|
|
159
163
|
- **写操作还要显式确认**:`scan` / `update` / `cancel` / `settings` / `open-downloads` 必须带 `confirm:true`。
|
|
160
164
|
- **不猜测静默参数**:优先用发布方在清单里声明的 `Silent` 参数;清单缺失时才按安装包类型用保守的默认开关。
|
|
165
|
+
- **执行前本地复核 SHA-256(1.3.5 起)**:`winget download` 完成后,按随包清单的
|
|
166
|
+
`InstallerSha256` 对流盘的安装包重新计算一次哈希再执行——这个文件紧接着可能以管理员
|
|
167
|
+
身份运行,中途被替换或损坏必须拦住;不一致就不执行、改用兜底。清单里配对不出本次
|
|
168
|
+
架构的哈希时跳过并记录,绝不拿别的架构的哈希误判。
|
|
161
169
|
- **需要管理员时自动提权**:机器级安装包(MSI、写入 `Program Files`)交给
|
|
162
170
|
`Start-Process -Verb RunAs` 的提权副本执行(可在设置里关掉「自动提权安装」);
|
|
163
171
|
UAC 为「始终通知」时每次安装会弹一次确认。
|
|
@@ -172,7 +180,8 @@ curl -X POST -H "Content-Type: application/json" \
|
|
|
172
180
|
|
|
173
181
|
- **Windows**:`winget.exe`(Microsoft「应用安装程序 / App Installer」)。没装 winget 时插件仍能列出注册表里的应用,但无法判断是否有新版本。
|
|
174
182
|
- **macOS**:无需额外依赖即可**列出**应用(直接读 `/Applications` 等目录里的 `.app`);要判断并执行更新需要 `brew`([Homebrew](https://brew.sh))。没装 Homebrew 时会照常列出应用,并说明「安装 Homebrew 后可一键升级 cask 应用」。
|
|
175
|
-
cask 目录(19 MB)走**官方 + 清华 / 中科大
|
|
183
|
+
cask 目录(19 MB)走**官方 + 清华 TUNA / 中科大 USTC 并发竞速**(阿里云镜像是过期快照、
|
|
184
|
+
会把「有更新」静默说成「已是最新」,1.3.4 起已剔除),单地址限时 60 秒,
|
|
176
185
|
全部失败时回退 30 天内的过期缓存;抓不到目录会在界面上给出红色警告横幅,而不是假装「无更新」。
|
|
177
186
|
- 不需要任何第三方 npm 依赖;宿主半身只用 Node 内置模块,浏览器半身只用宿主提供的 `react`。
|
|
178
187
|
|
package/lib/elevate.js
CHANGED
|
@@ -117,32 +117,67 @@ export async function runElevated(exe, args, opts = {}) {
|
|
|
117
117
|
const ps = powershellPath();
|
|
118
118
|
const dir = await mkdtemp(join(tmpdir(), "dsh-app-updater-elev-"));
|
|
119
119
|
const outFile = join(dir, "out.txt");
|
|
120
|
+
const errFile = join(dir, "err.txt");
|
|
120
121
|
const codeFile = join(dir, "code.txt");
|
|
122
|
+
const cancelFile = join(dir, "cancel.flag");
|
|
121
123
|
const helperFile = join(dir, "helper.ps1");
|
|
122
124
|
const launchFile = join(dir, "launch.ps1");
|
|
123
125
|
|
|
126
|
+
/**
|
|
127
|
+
* 助手脚本(提权副本里执行)。1.3.5 起改为「Start-Process + 轮询取消标记 + 杀进程树」:
|
|
128
|
+
* 旧写法 `& $exe` 同步阻塞,Node 侧取消/超时只能杀掉**启动器**——提权副本与安装器
|
|
129
|
+
* 在独立进程树里继续跑,队列却已进入下一个应用(并发双装 + 幽灵安装)。
|
|
130
|
+
* 助手自己就是提权进程,只有它杀得动自己启动的安装器树,所以取消通道放这里:
|
|
131
|
+
* Node 落一个 cancel.flag,助手 500ms 内看到就 taskkill /T /F 整棵树再收尾。
|
|
132
|
+
* PS 5.1 的 Start-Process -ArgumentList 给带空格参数不加引号,先手工包引号。
|
|
133
|
+
*/
|
|
124
134
|
const helper = [
|
|
125
135
|
"$ErrorActionPreference = 'Continue'",
|
|
126
136
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
127
137
|
"$OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
128
138
|
`$exe = ${psQuote(exe)}`,
|
|
129
139
|
`$argv = @(${(args || []).map(psQuote).join(", ")})`,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"
|
|
140
|
+
"$argv = @($argv | ForEach-Object { if (\"$_\" -match '\\s') { '\"' + $_ + '\"' } else { $_ } })",
|
|
141
|
+
`$cancelFile = ${psQuote(cancelFile)}`,
|
|
142
|
+
"$pi = $null",
|
|
143
|
+
"try {",
|
|
144
|
+
` $pi = Start-Process -FilePath $exe -ArgumentList $argv -PassThru -WindowStyle Hidden -RedirectStandardOutput ${psQuote(outFile)} -RedirectStandardError ${psQuote(errFile)}`,
|
|
145
|
+
"} catch {",
|
|
146
|
+
` Set-Content -Path ${psQuote(codeFile)} -Value 1 -Encoding ASCII`,
|
|
147
|
+
" exit 1",
|
|
148
|
+
"}",
|
|
149
|
+
"while (-not $pi.WaitForExit(500)) {",
|
|
150
|
+
" if (Test-Path $cancelFile) {",
|
|
151
|
+
" & taskkill.exe /PID $pi.Id /T /F 2>&1 | Out-Null",
|
|
152
|
+
" $pi.WaitForExit(10000) | Out-Null",
|
|
153
|
+
" break",
|
|
154
|
+
" }",
|
|
155
|
+
"}",
|
|
156
|
+
"$code = $pi.ExitCode",
|
|
157
|
+
// 读不到退出码时按失败处理(1):宁可多走一次 winget upgrade 兜底,
|
|
158
|
+
// 也不能伪造「退出码 0」让上层把没装成的东西判成成功。
|
|
159
|
+
"if ($null -eq $code) { $code = 1 }",
|
|
133
160
|
`Set-Content -Path ${psQuote(codeFile)} -Value $code -Encoding ASCII`,
|
|
161
|
+
// 保持旧版 2>&1 的现场:stderr 并入 out
|
|
162
|
+
`if (Test-Path ${psQuote(errFile)}) { $e = Get-Content -Raw ${psQuote(errFile)}; if ($e) { Add-Content -Path ${psQuote(outFile)} -Value $e } }`,
|
|
134
163
|
"exit $code",
|
|
135
164
|
"",
|
|
136
165
|
].join("\r\n");
|
|
137
166
|
|
|
138
|
-
//
|
|
167
|
+
// 启动器只负责「把助手脚本以管理员身份跑起来并等它退出」。取消/杀进程的职责全在
|
|
168
|
+
// 助手(它是提权进程,杀得动安装器树);启动器被 Node 杀掉也带不死助手,助手会
|
|
169
|
+
// 自己看到 cancel.flag 收尾并写退出码。不用 -Wait:那会让「杀启动器」把取消通道一起带走。
|
|
139
170
|
const launch = [
|
|
140
171
|
"$ErrorActionPreference = 'Stop'",
|
|
141
172
|
"try {",
|
|
142
|
-
` $p = Start-Process -FilePath ${psQuote(ps)} -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File', ${psQuote(helperFile)}) -Verb RunAs -WindowStyle Hidden -
|
|
173
|
+
` $p = Start-Process -FilePath ${psQuote(ps)} -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File', ${psQuote(helperFile)}) -Verb RunAs -WindowStyle Hidden -PassThru`,
|
|
143
174
|
`} catch { exit ${ELEVATION_CANCELLED} }`,
|
|
144
175
|
`if ($null -eq $p) { exit ${ELEVATION_CANCELLED} }`,
|
|
145
|
-
"
|
|
176
|
+
"while (-not $p.WaitForExit(1000)) {}",
|
|
177
|
+
"$c = $null",
|
|
178
|
+
"try { $c = $p.ExitCode } catch {}",
|
|
179
|
+
"if ($null -eq $c) { exit 1 }",
|
|
180
|
+
"exit $c",
|
|
146
181
|
"",
|
|
147
182
|
].join("\r\n");
|
|
148
183
|
|
|
@@ -154,11 +189,26 @@ export async function runElevated(exe, args, opts = {}) {
|
|
|
154
189
|
return fail(String((err && err.message) || err));
|
|
155
190
|
}
|
|
156
191
|
|
|
192
|
+
// 取消通道:signal 触发时落一个 cancel.flag,提权副本里的助手 500ms 内看到
|
|
193
|
+
// 就把安装器进程树杀掉(taskkill /T /F)并报告退出码。没有这一步,取消/超时
|
|
194
|
+
// 只会杀掉启动器,安装器在提权树里继续跑,而队列已经排到下一个应用。
|
|
195
|
+
let onCancel = null;
|
|
196
|
+
if (opts.signal) {
|
|
197
|
+
onCancel = () => {
|
|
198
|
+
writeFile(cancelFile, "1", "utf8").catch(() => {});
|
|
199
|
+
};
|
|
200
|
+
if (opts.signal.aborted) onCancel();
|
|
201
|
+
else opts.signal.addEventListener("abort", onCancel, { once: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
157
204
|
const res = await runProcess(
|
|
158
205
|
ps,
|
|
159
206
|
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launchFile],
|
|
160
207
|
{ timeoutMs, signal: opts.signal }
|
|
161
208
|
);
|
|
209
|
+
// 超时同样走取消通道(runProcess 的超时杀的是启动器;助手要等它自己轮询退出)
|
|
210
|
+
if (res.timedOut && onCancel) onCancel();
|
|
211
|
+
if (opts.signal && onCancel) opts.signal.removeEventListener("abort", onCancel);
|
|
162
212
|
|
|
163
213
|
let stdout = "";
|
|
164
214
|
try {
|
package/lib/engine.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { mkdir, readdir, stat, readFile, rm } from "node:fs/promises";
|
|
13
|
-
import { existsSync, lstatSync } from "node:fs";
|
|
13
|
+
import { createReadStream, existsSync, lstatSync } from "node:fs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
14
15
|
import { join, basename, isAbsolute } from "node:path";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
17
|
|
|
@@ -97,7 +98,10 @@ export function parseInstallerManifest(yamlText) {
|
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
// YAML 列表项(`- Architecture: x64`)标志着新 Installer 块开始:先清掉上一块
|
|
102
|
+
// 的架构,防止「块里没有 Architecture 键时拿别块的架构去配对哈希」。
|
|
103
|
+
if (/^-\s+\w/.test(trimmed)) info._lastArch = "";
|
|
104
|
+
const kv = trimmed.match(/^(?:-\s+)?(InstallerType|InstallerUrl|Scope|ElevationRequirement|ProductCode|Architecture|InstallerSha256):\s*(.+)$/);
|
|
101
105
|
if (kv) {
|
|
102
106
|
const value = stripQuotes(kv[2]);
|
|
103
107
|
switch (kv[1]) {
|
|
@@ -107,6 +111,17 @@ export function parseInstallerManifest(yamlText) {
|
|
|
107
111
|
case "InstallerUrl":
|
|
108
112
|
if (!info.installerUrl) info.installerUrl = value;
|
|
109
113
|
break;
|
|
114
|
+
case "Architecture":
|
|
115
|
+
// merged 清单里每个 Installer 块各带 Architecture + InstallerSha256。
|
|
116
|
+
// 配对收集,最后由 pickInstallerSha256 用实际架构或落盘文件名选定;
|
|
117
|
+
// 配对不出来时宁可不做校验,也绝不拿「别的架构的哈希」把正确文件判成篡改。
|
|
118
|
+
info._lastArch = value;
|
|
119
|
+
break;
|
|
120
|
+
case "InstallerSha256": {
|
|
121
|
+
const map = (info.sha256ByArch ||= {});
|
|
122
|
+
map[String(info._lastArch || "any").toLowerCase()] = value.toLowerCase();
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
110
125
|
case "Scope":
|
|
111
126
|
if (!info.scope) info.scope = value;
|
|
112
127
|
break;
|
|
@@ -132,6 +147,71 @@ function stripQuotes(value) {
|
|
|
132
147
|
return v;
|
|
133
148
|
}
|
|
134
149
|
|
|
150
|
+
/** 流式 SHA-256(安装包常有几百 MB~GB,绝不整读进内存)。失败返回 null。 */
|
|
151
|
+
export function sha256File(path) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const hash = createHash("sha256");
|
|
154
|
+
const stream = createReadStream(path);
|
|
155
|
+
stream.on("error", () => resolve(null));
|
|
156
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
157
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 从按架构收集的哈希里挑出「本次落盘这个文件」对应的声明哈希。
|
|
163
|
+
*
|
|
164
|
+
* @param {{sha256ByArch?: Record<string,string>}} info 清单解析结果
|
|
165
|
+
* @param {string} arch 本机/请求的架构(x64 / arm64 …)
|
|
166
|
+
* @param {string} fileName 实际下载到的文件名(merged 清单常把架构写进文件名)
|
|
167
|
+
* @returns {string|null} 期望哈希;无法可靠配对时 null(调用方据此跳过校验)
|
|
168
|
+
*/
|
|
169
|
+
export function pickInstallerSha256(info, arch, fileName) {
|
|
170
|
+
const map = info && info.sha256ByArch;
|
|
171
|
+
if (!map) return null;
|
|
172
|
+
const keys = Object.keys(map);
|
|
173
|
+
if (!keys.length) return null;
|
|
174
|
+
const wanted = String(arch || "").toLowerCase();
|
|
175
|
+
if (wanted && map[wanted]) return map[wanted];
|
|
176
|
+
// 文件名里带架构标记时用它配对。注意软件官网的命名习惯并不统一:
|
|
177
|
+
// Git 用 `-64-bit`,Node 用 `-x64`,VS Code 用 `arm64` —— 所以每个 arch 键
|
|
178
|
+
// 都带一组别名来匹配,而不是只做 `name.includes(key)`。
|
|
179
|
+
const ALIASES = { x64: ["x64", "64-bit", "64bit", "_x64", "-x64", "win64"], arm64: ["arm64", "aarch64", "winarm"], x86: ["x86", "32-bit", "32bit", "win32"] };
|
|
180
|
+
const name = String(fileName || "").toLowerCase();
|
|
181
|
+
for (const k of keys) {
|
|
182
|
+
if (k === "any") continue;
|
|
183
|
+
const probes = ALIASES[k] || [k];
|
|
184
|
+
if (probes.some((p) => name.includes(p))) return map[k];
|
|
185
|
+
}
|
|
186
|
+
// 只有一份哈希(绝大多数单架构包)时可以直接用它
|
|
187
|
+
if (keys.length === 1) return map[keys[0]];
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 校验已下载的安装包;返回 {ok, skipped, expected, actual, error}。 */
|
|
192
|
+
export async function verifyInstallerHash(installerPath, info, arch, log) {
|
|
193
|
+
const expected = pickInstallerSha256(info, arch, basename(installerPath));
|
|
194
|
+
if (!expected) {
|
|
195
|
+
if (log) log("info", "清单里没有可配对的 InstallerSha256,跳过本地复核(winget 下载时已校验过一次)");
|
|
196
|
+
return { ok: true, skipped: true };
|
|
197
|
+
}
|
|
198
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true };
|
|
199
|
+
const actual = await sha256File(installerPath);
|
|
200
|
+
if (!actual) {
|
|
201
|
+
return { ok: false, error: `安装包无法读取,无法校验哈希:${installerPath}` };
|
|
202
|
+
}
|
|
203
|
+
if (actual !== expected) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
expected,
|
|
207
|
+
actual,
|
|
208
|
+
error: `安装包哈希与清单声明不一致(可能被替换或下载损坏)\n 期望 ${expected}\n 实际 ${actual}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (log) log("info", `安装包哈希复核通过(SHA-256 ${actual.slice(0, 16)}…)`);
|
|
212
|
+
return { ok: true, expected, actual };
|
|
213
|
+
}
|
|
214
|
+
|
|
135
215
|
/** 根据安装包类型给出静默安装参数(清单没写时的兜底)。 */
|
|
136
216
|
export function defaultSilentArgs(installerType, filePath) {
|
|
137
217
|
const type = String(installerType || "").toLowerCase();
|
|
@@ -263,9 +343,14 @@ export async function scanApps({ settings, log, signal }) {
|
|
|
263
343
|
["list", "--accept-source-agreements", "--disable-interactivity"],
|
|
264
344
|
{ timeoutMs: 240000, signal }
|
|
265
345
|
);
|
|
346
|
+
// 取消必须向上抛,绝不能把「被 kill 剩下的半截 stdout」解析成清单去覆盖上一次完整结果
|
|
347
|
+
if (signal && signal.aborted) throw new Error("扫描已取消");
|
|
266
348
|
if (!listRes.ok && isWingetUnavailable(listRes)) {
|
|
267
349
|
warnings.push(`winget list 失败:${firstLine(outputText(listRes)) || "未知错误"}`);
|
|
268
350
|
wingetInfo.available = false;
|
|
351
|
+
} else if (!listRes.ok) {
|
|
352
|
+
// 超时被杀:stdout 是半截的。如实警告并降级到注册表源,而不是静默当成「就这么几个应用」。
|
|
353
|
+
warnings.push(`winget list ${listRes.error === "timeout" ? "超时" : `异常退出(exit ${listRes.code})`}:本次已安装清单可能不完整`);
|
|
269
354
|
} else {
|
|
270
355
|
const installed = parseInstalledList(listRes.stdout);
|
|
271
356
|
log("info", `winget 报告 ${installed.length} 个已安装条目`);
|
|
@@ -292,7 +377,11 @@ export async function scanApps({ settings, log, signal }) {
|
|
|
292
377
|
["upgrade", "--include-unknown", "--accept-source-agreements", "--disable-interactivity"],
|
|
293
378
|
{ timeoutMs: 240000, signal }
|
|
294
379
|
);
|
|
295
|
-
if (
|
|
380
|
+
if (signal && signal.aborted) throw new Error("扫描已取消");
|
|
381
|
+
// 只有 ok 才解析。原来的 `upRes.ok || upRes.stdout` 把超时/被杀残留的半截 stdout
|
|
382
|
+
// 字符串真值当成功 → 解析出残缺清单 → hasUpdate 全 false → 用户看到「无更新」。
|
|
383
|
+
// macOS 路径在 1.3.4 修过同款 bug(见 scanMacos),这次补上 Windows 侧。
|
|
384
|
+
if (upRes.ok) {
|
|
296
385
|
const upgradable = parseUpgradeList(upRes.stdout);
|
|
297
386
|
log("info", `软件目录报告 ${upgradable.length} 个可更新条目`);
|
|
298
387
|
for (const item of upgradable) {
|
|
@@ -319,15 +408,16 @@ export async function scanApps({ settings, log, signal }) {
|
|
|
319
408
|
}
|
|
320
409
|
}
|
|
321
410
|
} else {
|
|
322
|
-
warnings.push("
|
|
411
|
+
warnings.push(`查询可用更新失败(${upRes.error === "timeout" ? "超时" : `exit ${upRes.code}`}),本次只能显示已安装清单,可能漏报可更新应用`);
|
|
323
412
|
}
|
|
324
413
|
}
|
|
325
414
|
}
|
|
326
415
|
|
|
327
416
|
// 注册表兜底:补齐 winget 目录未收录的应用,并给 winget 条目补上发布者/安装位置
|
|
328
417
|
if (settings.includeRegistryOnly) {
|
|
418
|
+
if (signal && signal.aborted) throw new Error("扫描已取消");
|
|
329
419
|
log("info", "正在读取注册表已安装应用…");
|
|
330
|
-
const reg = await readRegistryApps();
|
|
420
|
+
const reg = await readRegistryApps(process.env, signal);
|
|
331
421
|
if (!reg.ok) {
|
|
332
422
|
warnings.push(`注册表读取失败:${reg.error || "未知错误"}`);
|
|
333
423
|
} else {
|
|
@@ -705,9 +795,12 @@ async function downloadInstaller(wingetPath, id, dir, log, signal, opts = {}) {
|
|
|
705
795
|
return { ok: false, error: err };
|
|
706
796
|
}
|
|
707
797
|
|
|
708
|
-
//
|
|
798
|
+
// 从输出里取落盘路径;取不到就按目录差异找。
|
|
799
|
+
// 中英双语都要认:本机 winget 是中文输出(「安装程序已下载:」),只匹配英文时
|
|
800
|
+
// 这里落到不可靠的目录 diff,diff 再失手就误报「没找到安装包」→ 触发 upgrade 兜底
|
|
801
|
+
// **把同一个包再下载一遍**。classifyWingetError / isArchNotFoundError 都是双语的。
|
|
709
802
|
let installer = null;
|
|
710
|
-
const m = text.match(/Installer downloaded
|
|
803
|
+
const m = text.match(/(?:Installer downloaded|安装程序已下载)\s*[::]\s*(.+?)\s*$/m);
|
|
711
804
|
if (m) {
|
|
712
805
|
const candidate = m[1].trim().replace(/^["']|["']$/g, "");
|
|
713
806
|
installer = isAbsolute(candidate) ? candidate : join(dir, basename(candidate));
|
|
@@ -969,13 +1062,22 @@ export async function runUpdates({ ids, settings, log, onProgress, signal, plan
|
|
|
969
1062
|
} else {
|
|
970
1063
|
entry.installer = dl.installer;
|
|
971
1064
|
entry.manifest = dl.manifest || null;
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1065
|
+
// 执行前本地复核 SHA-256:winget 下载时按清单校验过一次,但紧接着我们
|
|
1066
|
+
// 要(可能以**管理员**身份)执行这个文件,中途被替换/损坏就必须拦住。
|
|
1067
|
+
const hashCheck = await verifyInstallerHash(dl.installer, dl.info, env.wingetArch, log);
|
|
1068
|
+
entry.hash = hashCheck.skipped ? "skipped" : hashCheck.ok ? "verified" : "mismatch";
|
|
1069
|
+
if (!hashCheck.ok) {
|
|
1070
|
+
entry.error = hashCheck.error;
|
|
1071
|
+
log("error", `${id}:${hashCheck.error}`);
|
|
977
1072
|
} else {
|
|
978
|
-
|
|
1073
|
+
const inst = await runInstaller(dl.installer, dl.info, log, signal, ctx);
|
|
1074
|
+
if (inst.ok) {
|
|
1075
|
+
entry.ok = true;
|
|
1076
|
+
entry.installOk = true;
|
|
1077
|
+
entry.note = inst.note || null;
|
|
1078
|
+
} else {
|
|
1079
|
+
entry.error = inst.error;
|
|
1080
|
+
}
|
|
979
1081
|
}
|
|
980
1082
|
}
|
|
981
1083
|
|
|
@@ -1268,7 +1370,9 @@ async function runMacosUpdates({ ids, settings, log, onProgress, signal, env })
|
|
|
1268
1370
|
// 演练模式:只下载该 cask 对应的安装包(Homebrew 按本机架构取),不安装
|
|
1269
1371
|
log("warn", `${token}:演练模式,只下载安装包不安装`);
|
|
1270
1372
|
const fetchRes = await runBrew(brewPath, ["fetch", "--cask", token], { signal, timeoutMs: 60 * 60 * 1000 });
|
|
1271
|
-
if (fetchRes.
|
|
1373
|
+
if (signal.aborted || fetchRes.timedOut) {
|
|
1374
|
+
entry.error = signal.aborted ? "已取消" : "brew fetch 超时";
|
|
1375
|
+
} else if (fetchRes.ok) {
|
|
1272
1376
|
entry.ok = true;
|
|
1273
1377
|
entry.dryRun = true;
|
|
1274
1378
|
entry.note = "演练模式:已下载该应用对应的安装包,未安装";
|
|
@@ -1278,11 +1382,12 @@ async function runMacosUpdates({ ids, settings, log, onProgress, signal, env })
|
|
|
1278
1382
|
} else {
|
|
1279
1383
|
log("info", `brew upgrade --cask ${token}`);
|
|
1280
1384
|
const upRes = await runBrew(brewPath, ["upgrade", "--cask", token], { signal, timeoutMs: 2 * 60 * 60 * 1000 });
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1385
|
+
// 取消/超时优先判错:被 kill 的 brew 退出码不是 0,落进 else 会给出误导性原因;
|
|
1386
|
+
// 更要防的是「半跑被杀却退出码 0」被当成功
|
|
1387
|
+
if (signal.aborted) entry.error = "已取消";
|
|
1388
|
+
else if (upRes.timedOut) entry.error = "brew upgrade 超时(可能被杀)";
|
|
1389
|
+
else if (upRes.ok) entry.ok = true;
|
|
1390
|
+
else entry.error = firstLine(outputText(upRes)) || `brew upgrade 退出码 ${upRes.code}`;
|
|
1286
1391
|
}
|
|
1287
1392
|
|
|
1288
1393
|
if (entry.ok) {
|
|
@@ -1292,7 +1397,10 @@ async function runMacosUpdates({ ids, settings, log, onProgress, signal, env })
|
|
|
1292
1397
|
entry.verified = now;
|
|
1293
1398
|
log("success", `${token} 更新完成,当前版本 ${now}`);
|
|
1294
1399
|
} else {
|
|
1295
|
-
|
|
1400
|
+
// 复核读不到版本 ≠ 更新失败,但绝不能写成「更新完成」——与 Windows 路径
|
|
1401
|
+
// 「未复核必须标注」的准则保持一致(1.3.0 立的规矩,macOS 侧此前漏了)
|
|
1402
|
+
entry.note = "未复核:brew list 读不到更新后的版本";
|
|
1403
|
+
log("warn", `${token} 已执行升级,但无法复核版本(brew list 失败或读空)`);
|
|
1296
1404
|
}
|
|
1297
1405
|
} else {
|
|
1298
1406
|
log("error", `${token} 更新失败:${entry.error}`);
|
package/lib/index.js
CHANGED
|
@@ -86,6 +86,42 @@ export function hasConnectionCredentials(req) {
|
|
|
86
86
|
return Boolean(h.cookie || h.authorization);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* 兼容模式豁免的来源围栏:请求**若带** Origin / Referer,则其主机必须与本次请求的
|
|
91
|
+
* Host 一致(同源);`Sec-Fetch-Site: cross-site` 一律拒绝。
|
|
92
|
+
*
|
|
93
|
+
* 为什么需要它:豁免是给「宿主自己的载体(Electron 经 IPC 桥)」与「本机脚本
|
|
94
|
+
* (curl / PowerShell)」用的,这两类要么不带浏览器来源头,要么就是同源。而任意
|
|
95
|
+
* 第三方网页的 `fetch("http://127.0.0.1:<port>/dsh-app-updater/update",
|
|
96
|
+
* {method:"POST",mode:"no-cors",body:'{"confirm":true,"ids":[…]}'})` 天然不带
|
|
97
|
+
* Cookie,能绕过「无凭据才豁免」这一条,驱动「下载任意包 + 自动 UAC 提权静默安装」。
|
|
98
|
+
* 浏览器禁止脚本伪造 Origin / Sec-Fetch-Site(forbidden header),所以拿它做同源
|
|
99
|
+
* 判定是可靠的:跨站请求必然带上一个与 Host 不符的 Origin → 拒绝。
|
|
100
|
+
*
|
|
101
|
+
* 只拒绝「出现且不同源」的来源头,绝不用「有来源头就拒」:同源界面的 fetch POST
|
|
102
|
+
* 本来就会发 Origin(GET 还可能带 Referer),一刀切会把兼容模式想服务的那个界面打死。
|
|
103
|
+
*/
|
|
104
|
+
export function isCrossSiteBrowserRequest(req) {
|
|
105
|
+
const h = (req && req.headers) || {};
|
|
106
|
+
if (String(h["sec-fetch-site"] || "").toLowerCase() === "cross-site") return true;
|
|
107
|
+
const host = String(h.host || "").toLowerCase();
|
|
108
|
+
if (!host) return false;
|
|
109
|
+
const sameHost = (value) => {
|
|
110
|
+
const v = String(value || "").trim().toLowerCase();
|
|
111
|
+
if (!v) return true; // 头不存在 → 不是浏览器跨站请求
|
|
112
|
+
if (v === "null") return false; // opaque origin(file:// / data: 页面)
|
|
113
|
+
try {
|
|
114
|
+
const u = new URL(v.startsWith("http") ? v : `http://${v}`);
|
|
115
|
+
return u.host.toLowerCase() === host;
|
|
116
|
+
} catch {
|
|
117
|
+
return false; // 解析不了的来源,按不可信处理
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
if (h.origin && !sameHost(h.origin)) return true;
|
|
121
|
+
if (h.referer && !sameHost(h.referer)) return true;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
89
125
|
/**
|
|
90
126
|
* 兼容模式开关:只允许在用户**显式开启**时按「回环地址」放行。
|
|
91
127
|
*
|
|
@@ -161,9 +197,11 @@ export function admissionGate(req, res, connection, options = {}) {
|
|
|
161
197
|
denyAdmission(res, 403, "连接准入返回了无法识别的拒绝码,按默认拒绝处理");
|
|
162
198
|
return false;
|
|
163
199
|
}
|
|
164
|
-
if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req) && optIn) {
|
|
200
|
+
if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req) && !isCrossSiteBrowserRequest(req) && optIn) {
|
|
165
201
|
// 例外路径:宿主自己的载体(Electron 经 IPC 桥接的 fetch)与本机脚本都不带
|
|
166
|
-
// Cookie
|
|
202
|
+
// Cookie,没有可核验的凭据。只有用户显式开启兼容模式才退回回环信任;
|
|
203
|
+
// 且跨站浏览器请求(Origin/Referer 与 Host 不符)一律拒绝 —— 否则任意网页
|
|
204
|
+
// 的无 Cookie fetch 就能驱动「下载 + 提权静默安装」(CSRF)。
|
|
167
205
|
markLegacyAdmission(res);
|
|
168
206
|
return true;
|
|
169
207
|
}
|
|
@@ -179,7 +217,7 @@ export function admissionGate(req, res, connection, options = {}) {
|
|
|
179
217
|
);
|
|
180
218
|
return false;
|
|
181
219
|
}
|
|
182
|
-
if (optIn && loopback) {
|
|
220
|
+
if (optIn && loopback && !isCrossSiteBrowserRequest(req)) {
|
|
183
221
|
markLegacyAdmission(res);
|
|
184
222
|
return true;
|
|
185
223
|
}
|
|
@@ -187,7 +225,9 @@ export function admissionGate(req, res, connection, options = {}) {
|
|
|
187
225
|
res,
|
|
188
226
|
403,
|
|
189
227
|
optIn
|
|
190
|
-
?
|
|
228
|
+
? isCrossSiteBrowserRequest(req)
|
|
229
|
+
? "拒绝:兼容模式不接受跨站浏览器请求(Origin/Referer 与 Host 不一致)"
|
|
230
|
+
: "只接受本机(loopback)请求:当前宿主没有可用的 connection 准入服务"
|
|
191
231
|
: "拒绝访问:当前宿主没有 connection 准入服务,兼容模式(仅回环放行)未开启,按默认拒绝处理"
|
|
192
232
|
);
|
|
193
233
|
return false;
|
|
@@ -307,6 +347,8 @@ async function startScan(force) {
|
|
|
307
347
|
log: (level, text) => store.log(level, text),
|
|
308
348
|
signal: abort.signal,
|
|
309
349
|
});
|
|
350
|
+
// 已被新扫描接管(取消后重试)时本次结果作废,不覆盖新任务的状态
|
|
351
|
+
if (control.scanAbort !== abort) return;
|
|
310
352
|
store.state.env = result.env || store.state.env;
|
|
311
353
|
store.state.engine = result.engine || store.state.engine;
|
|
312
354
|
if (result.winget) store.state.winget = result.winget;
|
|
@@ -325,11 +367,21 @@ async function startScan(force) {
|
|
|
325
367
|
};
|
|
326
368
|
} catch (err) {
|
|
327
369
|
const message = String((err && err.message) || err);
|
|
328
|
-
|
|
329
|
-
store.log("
|
|
370
|
+
const cancelled = /已取消/.test(message);
|
|
371
|
+
if (cancelled) store.log("info", "扫描已取消");
|
|
372
|
+
else store.log("error", `扫描失败:${message}`);
|
|
373
|
+
// 只有本次任务还没被新任务接管时才写错误/收尾状态,避免旧任务把
|
|
374
|
+
// 新任务「running:true」的状态覆盖成 running:false;
|
|
375
|
+
// 取消不算故障:error 保持 null(红色横幅只留给真失败,1.3.4 的准则)
|
|
376
|
+
if (control.scanAbort === abort) {
|
|
377
|
+
store.state.scan = { ...store.state.scan, running: false, finishedAt: Date.now(), error: cancelled ? null : message };
|
|
378
|
+
}
|
|
330
379
|
} finally {
|
|
331
|
-
|
|
332
|
-
|
|
380
|
+
// 槽位所有权检查:旧任务不能清掉「取消后立刻重试」的新任务句柄
|
|
381
|
+
if (control.scanAbort === abort) {
|
|
382
|
+
control.scanAbort = null;
|
|
383
|
+
scanPromise = null;
|
|
384
|
+
}
|
|
333
385
|
store.save();
|
|
334
386
|
}
|
|
335
387
|
})();
|
package/lib/registry.js
CHANGED
|
@@ -51,9 +51,10 @@ export function findPowerShell(env = process.env) {
|
|
|
51
51
|
return join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function run(exe, args, timeoutMs = 60000) {
|
|
54
|
+
function run(exe, args, timeoutMs = 60000, signal = null) {
|
|
55
55
|
return new Promise((resolve) => {
|
|
56
56
|
const chunks = [];
|
|
57
|
+
const errChunks = [];
|
|
57
58
|
let done = false;
|
|
58
59
|
let timer = null;
|
|
59
60
|
const finish = (r) => {
|
|
@@ -64,7 +65,8 @@ function run(exe, args, timeoutMs = 60000) {
|
|
|
64
65
|
};
|
|
65
66
|
let child;
|
|
66
67
|
try {
|
|
67
|
-
|
|
68
|
+
// spawn 原生支持 signal:取消后立刻杀掉 PowerShell,而不是让它跑满 60 秒
|
|
69
|
+
child = spawn(exe, args, { windowsHide: true, shell: false, stdio: ["ignore", "pipe", "pipe"], signal: signal || undefined });
|
|
68
70
|
} catch (err) {
|
|
69
71
|
finish({ ok: false, stdout: "", stderr: String((err && err.message) || err), code: null });
|
|
70
72
|
return;
|
|
@@ -74,20 +76,25 @@ function run(exe, args, timeoutMs = 60000) {
|
|
|
74
76
|
finish({ ok: false, stdout: "", stderr: "timeout", code: null });
|
|
75
77
|
}, timeoutMs);
|
|
76
78
|
child.stdout.on("data", (d) => chunks.push(d));
|
|
77
|
-
|
|
79
|
+
// stderr 必须单独收。原来把它 push 进 stdout 的同一个数组:PowerShell 的一条无关
|
|
80
|
+
// 警告(执行策略提示、.NET 版本横幅)混进 JSON 串 → JSON.parse 抛错 → 整次注册表
|
|
81
|
+
// 读取被报成「失败」,用户以为这台机器读不到已装应用,实际数据是完好的。
|
|
82
|
+
child.stderr.on("data", (d) => errChunks.push(d));
|
|
78
83
|
child.on("error", (err) => finish({ ok: false, stdout: "", stderr: String(err && err.message), code: null }));
|
|
79
84
|
child.on("close", (code) => {
|
|
80
85
|
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
81
|
-
finish({ ok: code === 0, stdout, stderr: "", code });
|
|
86
|
+
finish({ ok: code === 0, stdout, stderr: Buffer.concat(errChunks).toString("utf8"), code });
|
|
82
87
|
});
|
|
83
88
|
});
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/**
|
|
87
92
|
* 读取注册表里的已安装应用。
|
|
93
|
+
* @param {Record<string,string>} [env]
|
|
94
|
+
* @param {AbortSignal} [signal] 扫描取消时立刻杀掉 PowerShell,不再空跑 60 秒
|
|
88
95
|
* @returns {Promise<{ok: boolean, apps: Array, error: string|null}>}
|
|
89
96
|
*/
|
|
90
|
-
export async function readRegistryApps(env = process.env) {
|
|
97
|
+
export async function readRegistryApps(env = process.env, signal = null) {
|
|
91
98
|
const exe = findPowerShell(env);
|
|
92
99
|
let dir = null;
|
|
93
100
|
try {
|
|
@@ -95,7 +102,8 @@ export async function readRegistryApps(env = process.env) {
|
|
|
95
102
|
const scriptPath = join(dir, "enum.ps1");
|
|
96
103
|
// 带 BOM 写出,Windows PowerShell 5.1 才会按 UTF-8 读中文
|
|
97
104
|
await writeFile(scriptPath, `\ufeff${PS_SCRIPT}`, "utf8");
|
|
98
|
-
const r = await run(exe, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath]);
|
|
105
|
+
const r = await run(exe, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath], 60000, signal);
|
|
106
|
+
if (signal && signal.aborted) return { ok: false, apps: [], error: "已取消" };
|
|
99
107
|
if (!r.ok) return { ok: false, apps: [], error: r.stderr || `powershell exit ${r.code}` };
|
|
100
108
|
const text = r.stdout.trim();
|
|
101
109
|
if (!text) return { ok: true, apps: [], error: null };
|
|
@@ -114,7 +122,10 @@ export function normalizeName(name) {
|
|
|
114
122
|
return String(name || "")
|
|
115
123
|
.toLowerCase()
|
|
116
124
|
.replace(/[((].*?[))]/g, "")
|
|
117
|
-
|
|
125
|
+
// 「版本」两侧不能用 \b:JS 的 \b 只认 ASCII 单词边界,「版」「本」都是非词字符,
|
|
126
|
+
// `\b版本\b` 是死分支 —— 结果「XXX 版本 1.2.3」与 winget 的「XXX」认不成同一条,
|
|
127
|
+
// 同一软件被重复列出。中文直接按字面匹配即可。
|
|
128
|
+
.replace(/\bversion\b|版本|\bv?\d+(\.\d+)+[a-z0-9.+]*\b/g, "")
|
|
118
129
|
.replace(/\b(x64|x86|64-bit|32-bit|amd64|arm64)\b/g, "")
|
|
119
130
|
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "");
|
|
120
131
|
}
|
package/lib/version.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* 版本比较:Windows 生态里版本串形状极不统一,这里实现一条保守但可用的规则。
|
|
3
3
|
*
|
|
4
4
|
* - 先按「数字段」切分比较(1.2.3.4 逐段比,缺段视为 0)
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
5
|
+
* - 数字段相同后,剩余的非数字尾缀按「自然序」比较:尾缀里的数字段按数值比,
|
|
6
|
+
* 其余字符按字典序(1.2.3-build10 > 1.2.3-build9,见 compareTails)
|
|
7
7
|
* - 无法解析时返回 0(视为相等),由调用方决定是否降级为「无法判断」
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -33,6 +33,46 @@ export function splitVersion(raw) {
|
|
|
33
33
|
return { nums, tail };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 尾缀自然序比较:把尾缀拆成「数字块 / 非数字块」交替序列,数字块按数值比,
|
|
38
|
+
* 非数字块按字典序比;一方先耗尽时「短的排前面」(-beta < -beta2,-rc1 < -rc10)。
|
|
39
|
+
*
|
|
40
|
+
* 为什么不能整串字典序:`-build9` > `-build10`、`-r99` > `-r100` —— 数字位数一变
|
|
41
|
+
* 顺序就翻转。这类尾缀在 Windows 生态里很常见(build 号、修订号、日期段)。
|
|
42
|
+
*/
|
|
43
|
+
export function compareTails(na, nb) {
|
|
44
|
+
const CHUNK = /(\d+)|([^\d]+)/g;
|
|
45
|
+
const take = (s) => {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = CHUNK.exec(s)) !== null) {
|
|
49
|
+
if (m[1] !== undefined) parts.push({ num: Number(m[1]), text: null });
|
|
50
|
+
else parts.push({ num: null, text: m[2] });
|
|
51
|
+
}
|
|
52
|
+
return parts;
|
|
53
|
+
};
|
|
54
|
+
CHUNK.lastIndex = 0;
|
|
55
|
+
const pa = take(na);
|
|
56
|
+
const pb = take(nb);
|
|
57
|
+
const len = Math.min(pa.length, pb.length);
|
|
58
|
+
for (let i = 0; i < len; i += 1) {
|
|
59
|
+
const x = pa[i];
|
|
60
|
+
const y = pb[i];
|
|
61
|
+
if (x.num !== null && y.num !== null) {
|
|
62
|
+
if (x.num !== y.num) return x.num > y.num ? 1 : -1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (x.text !== null && y.text !== null) {
|
|
66
|
+
if (x.text !== y.text) return x.text > y.text ? 1 : -1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// 数字 vs 字母:字母排在数字之后(beta < 数字尾段的情况极少,保守处理即可)
|
|
70
|
+
return x.num !== null ? -1 : 1;
|
|
71
|
+
}
|
|
72
|
+
if (pa.length === pb.length) return 0;
|
|
73
|
+
return pa.length > pb.length ? 1 : -1;
|
|
74
|
+
}
|
|
75
|
+
|
|
36
76
|
/** 比较两个版本串:a>b 返回 1,a<b 返回 -1,相等/无法判断返回 0。 */
|
|
37
77
|
export function compareVersions(a, b) {
|
|
38
78
|
const va = splitVersion(a);
|
|
@@ -52,8 +92,7 @@ export function compareVersions(a, b) {
|
|
|
52
92
|
if (!tb) return -1;
|
|
53
93
|
const na = ta.replace(/^[-+._\s]+/, '');
|
|
54
94
|
const nb = tb.replace(/^[-+._\s]+/, '');
|
|
55
|
-
|
|
56
|
-
return na > nb ? 1 : -1;
|
|
95
|
+
return compareTails(na, nb);
|
|
57
96
|
}
|
|
58
97
|
|
|
59
98
|
/** a 是否比 b 新(严格大于)。 */
|
package/lib/winget.js
CHANGED
|
@@ -277,6 +277,7 @@ export function runWinget(exe, args, options = {}) {
|
|
|
277
277
|
const timeout = options.timeoutMs || WINGET_TIMEOUT_MS;
|
|
278
278
|
return new Promise((resolve) => {
|
|
279
279
|
const chunks = [];
|
|
280
|
+
const errChunks = [];
|
|
280
281
|
let settled = false;
|
|
281
282
|
let timer = null;
|
|
282
283
|
let child;
|
|
@@ -308,16 +309,18 @@ export function runWinget(exe, args, options = {}) {
|
|
|
308
309
|
}
|
|
309
310
|
timer = setTimeout(() => {
|
|
310
311
|
try { child.kill(); } catch { /* 已退出 */ }
|
|
311
|
-
finish({ ok: false, code: null, stdout: Buffer.concat(chunks).toString("utf8"), stderr: "", error: "timeout" });
|
|
312
|
+
finish({ ok: false, code: null, stdout: Buffer.concat(chunks).toString("utf8"), stderr: Buffer.concat(errChunks).toString("utf8"), error: "timeout" });
|
|
312
313
|
}, timeout);
|
|
313
314
|
child.stdout.on("data", (d) => chunks.push(d));
|
|
314
|
-
|
|
315
|
+
// stderr 单独收桶:winget 的横幅/警告写进 stderr 时不污染定宽表解析(解析器
|
|
316
|
+
// 可能把折行的横幅误判成数据行),且 close 后 outputText 能拿到真实 stderr 分类错误。
|
|
317
|
+
child.stderr.on("data", (d) => errChunks.push(d));
|
|
315
318
|
child.on("error", (err) => {
|
|
316
319
|
finish({ ok: false, code: null, stdout: Buffer.concat(chunks).toString("utf8"), stderr: String(err && err.message), error: "spawn" });
|
|
317
320
|
});
|
|
318
321
|
child.on("close", (code) => {
|
|
319
322
|
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
320
|
-
finish({ ok: code === 0, code, stdout, stderr: "", error: null });
|
|
323
|
+
finish({ ok: code === 0, code, stdout, stderr: Buffer.concat(errChunks).toString("utf8"), error: null });
|
|
321
324
|
});
|
|
322
325
|
});
|
|
323
326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-app-updater",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
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/unit.mjs
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
displayWidth,
|
|
19
19
|
sliceByWidth,
|
|
20
20
|
} from "../lib/winget.js";
|
|
21
|
-
import { compareVersions, isNewer, looksLikeVersion, splitVersion } from "../lib/version.js";
|
|
22
|
-
import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew, isArchNotFoundError, explainInstallerExitCode, evaluateUpdateResult, findEdgeUpdate, updateHintFor } from "../lib/engine.js";
|
|
21
|
+
import { compareVersions, isNewer, looksLikeVersion, splitVersion, compareTails } from "../lib/version.js";
|
|
22
|
+
import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew, isArchNotFoundError, explainInstallerExitCode, evaluateUpdateResult, findEdgeUpdate, updateHintFor, pickInstallerSha256, verifyInstallerHash, sha256File } from "../lib/engine.js";
|
|
23
23
|
import { psQuote, ELEVATION_CANCELLED } from "../lib/elevate.js";
|
|
24
24
|
import {
|
|
25
25
|
applicationDirs,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
normalizeAppKey,
|
|
29
29
|
buildMacosItems,
|
|
30
30
|
} from "../lib/macos.js";
|
|
31
|
-
import { admissionGate, hasConnectionCredentials, resolveLegacyLoopbackOptIn, LEGACY_LOOPBACK_ENV } from "../lib/index.js";
|
|
31
|
+
import { admissionGate, hasConnectionCredentials, resolveLegacyLoopbackOptIn, isCrossSiteBrowserRequest, LEGACY_LOOPBACK_ENV } from "../lib/index.js";
|
|
32
32
|
import {
|
|
33
33
|
detectEnvironment,
|
|
34
34
|
describeEnvironment,
|
|
@@ -586,6 +586,98 @@ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "off" }) === f
|
|
|
586
586
|
ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 500, "准入抛错 → 500 而不是静默放行");
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
+
/* ---------------- 1.3.5 回归:尾缀自然序 / 本地哈希复核 ---------------- */
|
|
590
|
+
{
|
|
591
|
+
// 尾缀字典序的经典翻车:位数一变顺序就反(build10 < build9)
|
|
592
|
+
ok(compareVersions("1.2.3-build10", "1.2.3-build9") === 1, "尾缀数字按数值比:build10 > build9");
|
|
593
|
+
ok(compareVersions("5.8.r100", "5.8.r99") === 1, "尾缀 r100 > r99(字典序会判反)");
|
|
594
|
+
ok(compareVersions("1.2.3-beta2", "1.2.3-beta10") === -1, "beta2 < beta10");
|
|
595
|
+
ok(compareTails("rc9", "rc10") === -1, "compareTails 自然序");
|
|
596
|
+
ok(compareTails("a", "a") === 0, "compareTails 相等");
|
|
597
|
+
// 预发布语义不能因自然序而回退
|
|
598
|
+
ok(compareVersions("1.2.3", "1.2.3-beta") === 1, "正式版仍大于预发布");
|
|
599
|
+
ok(compareVersions("3.0.0-rc1", "3.0.0") === -1, "rc1 小于正式版");
|
|
600
|
+
}
|
|
601
|
+
{
|
|
602
|
+
const info = parseInstallerManifest(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "fixture-installer-git.yaml"), "utf8"));
|
|
603
|
+
ok(info.sha256ByArch && info.sha256ByArch.x64 === "dc6d3db061999136e139f31376c5582a9ed6f36a4ea12f15e54ad7fa2bfc0bf2", "清单解析出按架构配对的 InstallerSha256");
|
|
604
|
+
ok(pickInstallerSha256(info, "x64", "Git-2.56.0.3-64-bit.exe") === info.sha256ByArch.x64, "按架构键直接命中");
|
|
605
|
+
ok(pickInstallerSha256(info, "", "Git-2.56.0.3-64-bit.exe") === info.sha256ByArch.x64, "架构缺失时用文件名的 64-bit 别名配对");
|
|
606
|
+
ok(pickInstallerSha256({ sha256ByArch: { x64: "a".repeat(64), arm64: "b".repeat(64) } }, "", "node-v22-setup.msi") === null, "多架构且无法配对时宁可不校验(绝不误判篡改)");
|
|
607
|
+
}
|
|
608
|
+
{
|
|
609
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-hash-"));
|
|
610
|
+
const file = join(dir, "payload.bin");
|
|
611
|
+
writeFileSync(file, "hello dsh");
|
|
612
|
+
const hex = await sha256File(file);
|
|
613
|
+
ok(/^[0-9a-f]{64}$/.test(hex), "sha256File 输出 64 位十六进制");
|
|
614
|
+
const good = await verifyInstallerHash(file, { sha256ByArch: { any: hex } }, "", () => {});
|
|
615
|
+
ok(good.ok === true && good.skipped !== true, "哈希一致 → 校验通过");
|
|
616
|
+
const bad = await verifyInstallerHash(file, { sha256ByArch: { any: "f".repeat(64) } }, "", () => {});
|
|
617
|
+
ok(bad.ok === false && /不一致/.test(bad.error), "哈希不一致 → 拒绝执行并报期望/实际");
|
|
618
|
+
const skip = await verifyInstallerHash(file, {}, "", () => {});
|
|
619
|
+
ok(skip.ok === true && skip.skipped === true, "清单没有可配对哈希 → 跳过而不是失败");
|
|
620
|
+
rmSync(dir, { recursive: true, force: true });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/* ---------------- 1.3.5 第二批回归:来源围栏 / 归一化 / 双语匹配 ---------------- */
|
|
624
|
+
{
|
|
625
|
+
const base = { host: "127.0.0.1:7202" };
|
|
626
|
+
ok(isCrossSiteBrowserRequest(fakeReq({ headers: {} })) === false, "无 Origin/Referer(本机脚本 / IPC 桥)不算跨站");
|
|
627
|
+
ok(isCrossSiteBrowserRequest(fakeReq({ headers: { ...base, origin: "http://127.0.0.1:7202" } })) === false, "同源 Origin 放行");
|
|
628
|
+
ok(isCrossSiteBrowserRequest(fakeReq({ headers: { ...base, origin: "http://evil.example" } })) === true, "跨站 Origin 判为浏览器跨站");
|
|
629
|
+
ok(isCrossSiteBrowserRequest(fakeReq({ headers: { ...base, referer: "https://evil.example/page" } })) === true, "跨站 Referer 同样拦下");
|
|
630
|
+
ok(isCrossSiteBrowserRequest(fakeReq({ headers: { ...base, origin: "null" } })) === true, "opaque origin(file:// 页面)拒绝");
|
|
631
|
+
ok(isCrossSiteBrowserRequest(fakeReq({ headers: { ...base, "sec-fetch-site": "cross-site" } })) === true, "Sec-Fetch-Site: cross-site 直接拒绝");
|
|
632
|
+
// 兼容模式豁免必须带上来源围栏:任意网页的无 Cookie fetch 不得驱动提权安装
|
|
633
|
+
{
|
|
634
|
+
const res = fakeRes();
|
|
635
|
+
ok(
|
|
636
|
+
admissionGate(fakeReq({ headers: { ...base, origin: "http://evil.example" } }), res, null, { allowLegacyLoopback: true }) === false &&
|
|
637
|
+
res.statusCode === 403,
|
|
638
|
+
"开启兼容模式后,跨站浏览器请求仍然被豁免围栏拒绝(CSRF 面关闭)"
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
{
|
|
642
|
+
const res = fakeRes();
|
|
643
|
+
ok(
|
|
644
|
+
admissionGate(fakeReq({ headers: base }), res, null, { allowLegacyLoopback: true }) === true &&
|
|
645
|
+
res.headers["X-DSH-Admission"] === "loopback-legacy-optin",
|
|
646
|
+
"兼容模式下本机脚本(无来源头)照常放行"
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
{
|
|
650
|
+
const connection = { admit: () => ({ rejection: 401 }) };
|
|
651
|
+
const res = fakeRes();
|
|
652
|
+
ok(
|
|
653
|
+
admissionGate(fakeReq({ headers: { ...base, origin: "http://evil.example" } }), res, connection, { allowLegacyLoopback: true }) === false,
|
|
654
|
+
"有 connection 时,401+回环+显式 opt-in 也挡不住跨站 Origin"
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
{
|
|
659
|
+
// \b版本\b 是死分支(JS \b 只认 ASCII 边界),修复后含「版本」的 DisplayName 能与 winget 条目归并
|
|
660
|
+
ok(normalizeName("Google Chrome 版本 141.0.7357") === normalizeName("Google Chrome"), "中文名「版本 X」后缀被剥掉");
|
|
661
|
+
ok(normalizeName("微信 版本 8.0") === normalizeName("微信"), "无空格「版本」也能剥");
|
|
662
|
+
}
|
|
663
|
+
{
|
|
664
|
+
// 中文 winget 的落盘行(「安装程序已下载:」)必须与英文一样被识别,否则触发重复下载
|
|
665
|
+
const RE = /(?:Installer downloaded|安装程序已下载)\s*[::]\s*(.+?)\s*$/m;
|
|
666
|
+
ok(RE.test("安装程序已下载: D:\\x\\Git-2.56.0.3-64-bit.exe"), "中文冒号/中文文案匹配");
|
|
667
|
+
ok(RE.test("Installer downloaded: D:\\x\\a.exe"), "英文仍匹配");
|
|
668
|
+
const m = "安装程序已下载: C:\\d\\Git-64-bit.exe".match(RE);
|
|
669
|
+
ok(m && m[1] === "C:\\d\\Git-64-bit.exe", "捕获路径");
|
|
670
|
+
}
|
|
671
|
+
{
|
|
672
|
+
// Windows 扫描:取消必须抛错,绝不把半截输出当「0 个可更新」覆盖上次结果(对抗审查 #1)
|
|
673
|
+
const scanSrc = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../lib/engine.js"), "utf8");
|
|
674
|
+
const winScan = scanSrc.slice(scanSrc.indexOf("export async function scanApps"), scanSrc.indexOf("export async function runUpdates"));
|
|
675
|
+
ok((winScan.match(/signal && signal\.aborted/g) || []).length >= 3, "scanApps 的 winget 路径有取消检查");
|
|
676
|
+
// 只看真实代码行(注释里会引用旧写法):`if (upRes.ok || upRes.stdout)` 必须已消失
|
|
677
|
+
const codeOnly = winScan.split("\n").filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*")).join("\n");
|
|
678
|
+
ok(!/upRes\.ok \|\| upRes\.stdout/.test(codeOnly), "半截 stdout 不再被当真值(upRes.ok || upRes.stdout 已移除)");
|
|
679
|
+
}
|
|
680
|
+
|
|
589
681
|
/* ---------------- 结果 ---------------- */
|
|
590
682
|
console.log(`\n${failures === 0 ? "全部通过" : "存在失败"}:${checks - failures}/${checks}`);
|
|
591
683
|
process.exit(failures === 0 ? 0 : 1);
|