dsh-app-updater 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,94 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.3.1] — 2026-11-01
6
+
7
+ ### 安全
8
+
9
+ - **「仅凭回环地址放行」改为默认拒绝**(插件市场 dshopen.org 对 1.3.0 的复核意见):
10
+ 1.3.0 接入 `connection` 准入后仍留了一条退回路径 —— 宿主没有 `connection` 服务,或请求
11
+ 不带任何连接凭据且来自回环地址时,直接按「本机信任」放行。在没有准入服务的旧宿主上,
12
+ 任何本机进程(以及能向回环发起请求的页面)都能借此调用 `/update`、`/settings` 等安装接口,
13
+ 等于绕过认证。1.3.1 起这条路径**默认关闭**:
14
+ - 宿主有 `connection`:一律以 `admit()` 的结果为准,`401` / `403` / `409` 原样返回
15
+ (响应头 `X-DSH-Admission: denied`)。只有「401 + 回环 + 无凭据 + 用户显式开启兼容模式」
16
+ 这一种例外,且必须在下面第 2 条的前提下才成立。
17
+ - 宿主没有 `connection`:**默认 403**,不再自动相信回环地址。
18
+ - **兼容模式必须由用户显式开启**(保留给确实跑在旧宿主上的用户):插件配置
19
+ `allowLegacyLoopback: true`(`cordis.patch.yml` 覆盖同名条目)或环境变量
20
+ `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`,两者默认都没有。开启后:
21
+ - 放行的请求带 `X-DSH-Admission: loopback-legacy-optin` 与 `X-DSH-Admission-Warning`;
22
+ - `state.json` / `health.json` 新增 `admission` 字段(`mode` / `service` /
23
+ `allowLegacyLoopback` / `warning`),复核时不必读代码就能确认当前路径;
24
+ - 设置页顶部显示红色警示条,日志写一条 `warn`。
25
+ - **准入拒绝统一返回 JSON**:`401`/`403`/`409` 带 `{ ok:false, error, admission:"denied" }`,
26
+ 不再用纯文本,排障时能直接看出是被准入挡掉的。
27
+
28
+ ### 测试
29
+
30
+ - `test/unit.mjs`:准入用例从 6 项扩到 25 项,覆盖兼容模式开关(配置 / 环境变量 / 缺省关闭)、
31
+ 无 `connection` 服务默认拒绝、显式开启后放行、非回环仍拒绝、`409`、带凭据未认证不放行等。
32
+ - 新增 `test/deny.mjs`:起真实 HTTP 服务,验证无 `connection` 且未开兼容模式时**每一条路由**
33
+ 都是 `403 + X-DSH-Admission: denied`;再验证显式开启后放行并标注 opt-in 响应头。
34
+ - `test/render.mjs`:新增兼容模式警示条 4 项(开启显示、样式、文案、默认不显示)。
35
+ - 自测命令更新为 `node test/unit.mjs && node test/render.mjs && node test/deny.mjs`。
36
+
37
+ ## [1.3.0] — 2026-10-31
38
+
39
+ ### 修复
40
+
41
+ - **机器级安装全部失败的问题**:进程没有管理员令牌时,MSI 直接失败(msiexec 1603,
42
+ MSI 日志里是 `Error 1925: You do not have sufficient privileges…`),而部分安装器是
43
+ **静默退出 0 却什么都没装**(实测 Logitech G HUB)。两个现象同源,都是权限。
44
+ - **安装后复核**:退出码 0 不再等于成功。安装完成后用「安装前版本 / 安装后版本 / 目标版本」
45
+ 判断是否真的生效,没生效就如实报失败 —— 原来只看退出码,会把 G HUB 那种
46
+ 「退出码 0、版本没变」的情况误报成「更新完成」。
47
+ - **架构过滤回退**:`winget download/upgrade --architecture x64` 在清单里没有该架构时现在能
48
+ 正确识别(中文报错是「找不到适用的安装程序」,旧正则只匹配了「没有适用」)并退回不限架构
49
+ 重试;`winget upgrade` 兜底同时补上 `--include-unknown`(虎牙直播的已装版本被读成 Unknown,
50
+ 不带它直接「目录里没有适用的更新」)。
51
+ - **Edge**:winget 目录里的 `Microsoft.Edge` 是企业版 MSI,与本机消费版 Edge 的安装技术不匹配,
52
+ winget 直接拒绝(`0x8A15008E`「安装技术与当前安装的版本不同,请卸载包并安装较新版本」),
53
+ 现在先走 Edge 自带的更新服务 `MicrosoftEdgeUpdate.exe /ua`;若它没生效(实测该机的
54
+ EdgeUpdate 引擎目录是 `1.3.311.5` 而服务主程序还是 `1.3.275.13`,服务起不来,`/ua` 直接
55
+ `0x80070002`),自动退回安装 winget 目录里的官网全量包(企业版 MSI,提权执行)——
56
+ 实测这一步能把 Edge 正常装到目标版本,winget 拒绝执行只是因为它的策略。
57
+ - **读不到版本的应用**:虎牙直播的卸载信息里没有版本号,winget 永远显示 `Unknown`。
58
+ 复核阶段不再把它误判为失败,改为「已安装、未复核」写进结果与日志。
59
+ - **「安装包不生效」的软件给出可操作提示**:实测 Logitech G HUB 的更新包早已下载到
60
+ `%ProgramData%\LGHUB\next.json` 指向的 depots(build 880806 / 2026.7.992920),但安装器
61
+ `--silent` 与 `winget upgrade` 都会在 3 秒内退出 0 且什么都不装,只有**启动 G HUB 本体**
62
+ 之后 `current.json` 才跳到新版本。插件承诺不启动用户的软件,所以这类情况现在会在失败
63
+ 原因里直接给出「手动打开一次该应用即可完成升级」的提示(`updateHintFor`)。
64
+ - **MSI 日志**:MSI 安装现在带 `/l*v <下载目录>\<包名>.msi.log`,失败时把日志路径写进错误信息
65
+ (「Error 1925」就是这么被找到的)。
66
+ - **残留清单**:关闭「保留安装包」时,现在连 winget 落盘的 `.yaml` 清单一并删除。
67
+
68
+ ### 新增
69
+
70
+ - **自动提权安装**(`lib/elevate.js`):需要管理员的安装类命令交给 `Start-Process -Verb RunAs`
71
+ 的提权副本执行,退出码与输出写进临时文件后读回。新增开关「自动提权安装」(默认开启)。
72
+ UAC 为「始终通知」时每次安装会弹一次确认;把 `ConsentPromptBehaviorAdmin` 设为 0
73
+ (管理员静默提权)时完全无感。
74
+ - 说明:提权副本是独立进程,取消更新只会停掉启动器,已经开始的那次安装会自己跑完。
75
+
76
+ ### 安全
77
+
78
+ - **接入宿主 connection 准入**(插件市场 dshopen.org 对 1.2.0 的复核意见):所有
79
+ `/dsh-app-updater/*` 路由先过 `ctx.connection.admit(request)` —— DSH 用来做 Host/Origin
80
+ 信任校验与浏览器会话 / API 认证的那套边界,宿主自己的 `/api` 路由用的就是它。
81
+ 未通过准入的请求直接得到 `401`/`403`;带凭据的请求一律以准入结果为准。
82
+ 只有在宿主没有 `connection` 服务、或者请求不带任何连接凭据且来自回环地址时,才退回旧的
83
+ 「回环」规则,并在响应头标注 `X-DSH-Admission: loopback-legacy`(宿主自己的 Electron
84
+ 载体与本机脚本都不带 Cookie,需要一个不误伤它们的路径)。
85
+ - 因此 README 里「用 curl 直接改设置」的例子现在需要带上 DSH 的会话 Cookie,或在
86
+ `connection` 不可用的宿主上从回环地址调用。
87
+
88
+ ### 说明
89
+
90
+ - 读类命令(`winget list` / `upgrade --include-unknown` / `--version`)不提权。
91
+ - `update.results[]` 新增 `installOk`(安装器自身是否报成功)与 `manifest`(落盘的清单位置)。
92
+
5
93
  ## [1.2.0] — 2026-10-31
6
94
 
7
95
  ### 新增
package/CONTRACT.md CHANGED
@@ -27,10 +27,28 @@ 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
+ `409`(连接被占用),响应头带 `X-DSH-Admission: denied`;写操作再要求 `confirm: true`。
46
+
47
+ **默认拒绝**(1.3.1 起):宿主没有 `connection` 服务时不再「仅凭回环地址放行」,而是 `403`。
48
+ 只有用户显式开启兼容模式(插件配置 `allowLegacyLoopback: true` 或环境变量
49
+ `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`)时,回环请求才放行,并带
50
+ `X-DSH-Admission: loopback-legacy-optin` 与 `X-DSH-Admission-Warning`(见 `lib/index.js` 的
51
+ `admissionGate` / `resolveLegacyLoopbackOptIn`)。
34
52
 
35
53
  ### GET /dsh-app-updater/state.json
36
54
 
@@ -48,7 +66,9 @@ dsh-app-updater/
48
66
  "update": { "running": false, "total": 0, "done": 0, "current": null, "error": null },
49
67
  "apps": { "scannedAt": 0, "stats": { "total": 0, "upgradable": 0, "unsupported": 0 }, "items": [], "engine": "winget" },
50
68
  "log": [ { "at": 0, "level": "info|warn|error|success", "text": "..." } ],
51
- "settings": { "includeStoreApps": true, "includeRegistryOnly": true, "downloadDir": "", "keepInstallers": true, "hashFallbackUpgrade": true, "dryRun": false, "matchSystemArch": true }
69
+ "settings": { "includeStoreApps": true, "includeRegistryOnly": true, "downloadDir": "", "keepInstallers": true, "hashFallbackUpgrade": true, "dryRun": false, "matchSystemArch": true, "autoElevate": true },
70
+ // 准入现状,供界面与复核确认当前走哪条路径(默认 "deny" 不会出现在能读到响应的地方)
71
+ "admission": { "mode": "connection|connection+loopback-legacy|loopback-legacy-optin|deny", "service": true, "allowLegacyLoopback": false, "warning": null }
52
72
  }
53
73
  ```
54
74
 
@@ -81,11 +101,30 @@ body `{ "confirm": true, "force": true }` → `{ "ok": true, "started": true }`
81
101
  ### POST /dsh-app-updater/update
82
102
 
83
103
  body `{ "confirm": true, "ids": ["Google.Chrome", "..."], "mode": "silent" }`
84
- → `{ "ok": true, "started": true, "queued": 3 }`(串行执行,逐条下载官网全量包 → 校验 → 静默安装)
104
+ → `{ "ok": true, "started": true, "queued": 3 }`
105
+ (串行执行,逐条:下载官网全量包 → 校验哈希 → 静默安装(需要管理员时自动提权)→ 复核版本。
106
+ Edge 由 `MicrosoftEdgeUpdate.exe /ua` 处理。`settings.dryRun` 时只下载、不安装、不复核。)
107
+
108
+ `update.results[]`(每条一次更新):
109
+
110
+ ```jsonc
111
+ {
112
+ "id": "Microsoft.Edge",
113
+ "ok": false, // 最终结论:复核不通过就是 false,哪怕安装器报成功
114
+ "installOk": true, // 安装器自身是否报成功(退出码 0 / winget 成功)
115
+ "error": "安装报告成功,但没有生效:版本没有变化(仍为 156.0.4314.50,目标 156.0.4314.58)",
116
+ "installer": "C:\\...\\Microsoft Edge_156.0.4314.58_Machine_X64_msi_en-US.msi",
117
+ "manifest": "C:\\...\\Microsoft Edge_156.0.4314.58_Machine_X64_msi_en-US.yaml",
118
+ "verified": "156.0.4314.50", // 复核时读到的已装版本
119
+ "selfUpdater": "edgeupdate", // 走自带更新服务时存在
120
+ "note": "已交给 Microsoft Edge 更新服务,等待版本就位",
121
+ "durationMs": 33288
122
+ }
123
+ ```
85
124
 
86
125
  ### POST /dsh-app-updater/cancel
87
126
 
88
- → `{ "ok": true }`(取消尚未开始的项目;正在安装的进程会收到终止请求)
127
+ → `{ "ok": true }`(取消尚未开始的项目;提权副本是独立进程,取消只会停掉启动器,已经开始的那次安装会自己跑完)
89
128
 
90
129
  ### POST /dsh-app-updater/settings
91
130
 
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:
@@ -109,12 +110,36 @@ curl -X POST -H "Content-Type: application/json" \
109
110
 
110
111
  ## Safety boundaries
111
112
 
112
- - **Every write requires loopback + explicit confirmation**: `scan` / `update` / `cancel` /
113
- `settings` / `open-downloads` accept only requests from loopback with `confirm:true`.
113
+ - **Every route is deny-by-default**: each request first goes through `ctx.connection.admit(request)`
114
+ — DSH's Host/Origin fence plus browser-session / API authentication, the same gate the host uses for
115
+ its own `/api` routes. Rejected requests get `401` / `403` / `409` with `X-DSH-Admission: denied`.
116
+ - **Legacy loopback mode is off by default** (since 1.3.1): when the host has no `connection`
117
+ service, requests are no longer trusted just because they come from loopback — they get `403`.
118
+ If you really need it on an old host, opt in explicitly, either via plugin config:
119
+
120
+ ```yaml
121
+ - id: dsh-app-updater
122
+ config:
123
+ allowLegacyLoopback: true
124
+ ```
125
+
126
+ or by exporting `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1` before starting DSH. Once enabled, allowed
127
+ requests carry `X-DSH-Admission: loopback-legacy-optin` and `X-DSH-Admission-Warning`, the
128
+ `admission` field in `state.json` / `health.json` reports `mode: "loopback-legacy-optin"`, the
129
+ settings page shows a red banner, and a `warn` line is written to the log.
130
+ - **Every write still requires explicit confirmation**: `scan` / `update` / `cancel` /
131
+ `settings` / `open-downloads` require `confirm:true`.
114
132
  - **No guessed silent switches**: the publisher's declared `Silent` switches are preferred;
115
133
  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.
134
+ - **Elevation only for installs, and on demand**: install commands that need administrator rights
135
+ (MSI packages, installers writing into `Program Files`, the `winget upgrade` fallback) run in an
136
+ elevated copy through `Start-Process -Verb RunAs`. With UAC set to "always notify" you get one
137
+ prompt per install; with `ConsentPromptBehaviorAdmin = 0` it is completely silent. Reads
138
+ (`winget list` / `upgrade --include-unknown` / `--version`) never elevate.
139
+ - **A zero exit code is not success**: after installing, the plugin compares the installed version
140
+ against the target version and reports a failure when nothing actually changed — machine-scope
141
+ installers silently exit 0 without installing anything when they lack administrator rights
142
+ (Logitech G HUB used to be reported as "updated" that way).
118
143
  - **System components** (`.NET`, VC++ runtimes, …) are listed but not updated by default.
119
144
  - **Only its own directory is written**: state and logs live in `~/.dsh/dsh-app-updater/`.
120
145
 
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
 
@@ -109,14 +113,39 @@ curl -X POST -H "Content-Type: application/json" \
109
113
  `downloadDir` 会被校验:必须留空(用默认目录)或是绝对路径(`D:\Apps`)/UNC 路径(`\\server\share`),
110
114
  否则返回 `400` 并在面板里显示红字提示,不会把安装包写到意料之外的位置。
111
115
 
116
+ > 这些路由**默认拒绝**:请求必须通过宿主的 `connection` 准入(浏览器里就是带上 DSH 页面的会话
117
+ > Cookie),或在**没有 `connection` 服务的旧宿主**上由你显式开启兼容模式(见「安全边界」)。
118
+ > 仅凭「来自 127.0.0.1」不再放行 —— 1.3.1 已把这条路径改成默认拒绝。
119
+
112
120
  ---
113
121
 
114
122
  ## 安全边界
115
123
 
116
- - **写操作全部要求本机来源 + 显式确认**:`scan` / `update` / `cancel` / `settings` / `open-downloads` 只接受 loopback 请求且必须带 `confirm:true`。
124
+ - **路由默认拒绝**:每个请求先过 `ctx.connection.admit(request)` —— DSH 用来做
125
+ Host/Origin 信任校验与浏览器会话 / API 认证的那套边界(宿主自己的 `/api` 路由用的就是它),
126
+ 未通过的请求直接得到 `401` / `403` / `409`,响应头带 `X-DSH-Admission: denied`。
127
+ - **兼容模式默认关闭**(1.3.1 起):宿主没有 `connection` 服务时**不再**「仅凭回环地址放行」,
128
+ 一律 `403`。旧宿主上确实需要时,必须由你**显式**开启,二选一:
129
+ - 插件配置(`cordis.patch.yml` 里覆盖同名条目):
130
+
131
+ ```yaml
132
+ - id: dsh-app-updater
133
+ config:
134
+ allowLegacyLoopback: true
135
+ ```
136
+
137
+ - 或启动 DSH 前设置环境变量 `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`。
138
+
139
+ 开启后:放行的回环请求带 `X-DSH-Admission: loopback-legacy-optin` 与
140
+ `X-DSH-Admission-Warning`,`state.json` / `health.json` 的 `admission` 字段会标明
141
+ `mode: "loopback-legacy-optin"`,设置页顶部也会出现红色提示条;日志里记一条 `warn`。
142
+ - **写操作还要显式确认**:`scan` / `update` / `cancel` / `settings` / `open-downloads` 必须带 `confirm:true`。
117
143
  - **不猜测静默参数**:优先用发布方在清单里声明的 `Silent` 参数;清单缺失时才按安装包类型用保守的默认开关。
118
- - **不静默提权**:需要管理员权限的安装包会正常弹出 UAC,由你决定是否放行。
119
- - **安装后复核版本**:更新完会重新查询一次版本并记录,日志里能看到「更新完成,当前版本 X」。
144
+ - **需要管理员时自动提权**:机器级安装包(MSI、写入 `Program Files`)交给
145
+ `Start-Process -Verb RunAs` 的提权副本执行(可在设置里关掉「自动提权安装」);
146
+ UAC 为「始终通知」时每次安装会弹一次确认。
147
+ - **安装后复核版本**:用「安装前 / 安装后 / 目标」三个版本判断这次更新是否真的生效 ——
148
+ 退出码 0 不再等于成功(机器级安装包在没提权时会静默退出 0 却什么都没装)。
120
149
  - **不做系统组件更新**:`.NET` 运行时、VC++ 运行库等仍会列出来,但默认不作为一键更新的目标。
121
150
  - **只写自己的目录**:状态与日志写在 `~/.dsh/dsh-app-updater/`,不碰 profile 与宿主配置。
122
151
 
@@ -181,6 +210,15 @@ node test/harness.mjs --scan # 用假 cordis ctx + 真 HTTP 把宿主半身
181
210
 
182
211
  还可以在设置面板里关掉「按本机架构安装包」——关闭后把选择权交回 winget 自己(Homebrew 一直按本机架构)。
183
212
 
213
+ 机器级安装(MSI、写入 `Program Files`)需要管理员权限,否则 MSI 会返回 1603(MSI 日志里是
214
+ `Error 1925: You do not have sufficient privileges…`),部分安装器则可能**静默退出 0 却什么都没装**。
215
+ 因此安装类命令默认自动提权执行(设置里的「自动提权安装」,默认开启):安装包本体与
216
+ `winget upgrade` 兜底都交给 `Start-Process -Verb RunAs` 的提权副本,装完再按
217
+ 「安装前版本 / 安装后版本 / 目标版本」复核这次更新是否真的生效。
218
+ UAC 为「始终通知」时每次安装会弹一次确认;把 DSH 本身以管理员身份运行则不会再弹。
219
+ Edge 走它自带的更新服务:winget 目录里的 `Microsoft.Edge` 是企业版 MSI,与本机消费版的
220
+ 安装技术不匹配(`0x8A15008E`),winget 会直接拒绝。
221
+
184
222
  > macOS 的 Homebrew 路径是按 Homebrew 官方 CLI 行为实现的,识别与解析逻辑有单测覆盖;
185
223
  > 但本插件主要在 Windows 上开发,macOS 上的安装/更新没有真机验证过,遇到问题请开 Issue。
186
224
 
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",
@@ -195,6 +200,8 @@ window.__ModuleLoader__.load({
195
200
 
196
201
  /* 演练模式警示条 */
197
202
  ".appupd-banner{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:8px;border:1px solid rgba(251,191,36,.5);background:rgba(251,191,36,.12);color:#fbbf24;font-size:12px;}",
203
+ /* 兼容模式(仅回环放行)警示条:默认关闭,开启时用红色提醒 */
204
+ ".appupd-banner-warn{border-color:rgba(239,68,68,.55);background:rgba(239,68,68,.12);color:#f87171;}",
198
205
 
199
206
  /* 插件设置面板 */
200
207
  ".appupd-panel{border:1px solid var(--dsw-alias-border-l1,#4b5563);border-radius:8px;overflow:hidden;}",
@@ -922,6 +929,21 @@ window.__ModuleLoader__.load({
922
929
  )
923
930
  : null;
924
931
 
932
+ /* 兼容模式:只有用户显式开启(插件配置 / 环境变量)才会出现,默认不显示 */
933
+ const admission = (state && state.admission) || null;
934
+ const legacyOn = Boolean(admission && admission.allowLegacyLoopback);
935
+ const legacyBanner = legacyOn
936
+ ? h(
937
+ "div",
938
+ { className: "appupd-banner appupd-banner-warn", role: "alert" },
939
+ h(
940
+ "span",
941
+ null,
942
+ "兼容模式已开启:没有连接凭据的本机回环请求会被放行(旧宿主没有 connection 准入时才会用到)。非必要请关闭,见 README 的「安全」一节。"
943
+ )
944
+ )
945
+ : null;
946
+
925
947
  const switchEl = (key) => {
926
948
  const on = Boolean(settings[key]);
927
949
  return h("button", {
@@ -1190,6 +1212,9 @@ window.__ModuleLoader__.load({
1190
1212
  /* 3. 演练模式提示 */
1191
1213
  dryRunBanner,
1192
1214
 
1215
+ /* 3b. 兼容模式(仅回环放行)提示:默认不出现 */
1216
+ legacyBanner,
1217
+
1193
1218
  /* 4. 进度 */
1194
1219
  progress,
1195
1220
 
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
+ }