dsh-app-updater 1.3.7 → 1.3.8

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,84 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.3.8] — 2026-11-03
6
+
7
+ 收口《README-for-author》与《DIAGNOSIS.zh-CN》里**两条尚未落地的建议**。
8
+ 四个补丁(brew 失败被伪装成 0 个应用、cask 目录竞速、扫描警告上界面、findBrew 断言)
9
+ 在 1.3.3–1.3.5 已经并入,本版**不再重复修**,只处理剩下的两条。
10
+
11
+ ### 新增
12
+
13
+ - **「非 Homebrew 管理」直接给可执行的下一步**(`lib/macos.js` + `lib/client.js`)。
14
+ 这是 Mac 上最常见的状态(绝大多数应用是拖 DMG 装的),以前只回一句「非 Homebrew 管理」,
15
+ 用户知道「不能更新」却不知道「那要怎么办」。现在引擎为这类应用附带 `adoptHint`:
16
+ - 能对上 cask → `brew install --cask --adopt <真实 token>`(接管现有 `.app`,不重装、不丢配置);
17
+ - 对不上 cask → 给 `brew install --cask --adopt <token>` 占位写法,仍然指明方向;
18
+ - 系统组件 / App Store 应用 / 已由 Homebrew 管理的应用**不**给这条提示(避免误导)。
19
+ 界面把提示拼在原因后面显示(`原因:命令`),没有提示时只显示原因,不留悬空冒号。
20
+
21
+ - **平台专属设置不再跨平台落盘**(`lib/state.js`)。界面本来就按平台裁剪开关,但
22
+ `/settings` 接口和 `state.json` 不看平台 —— macOS 上照样能把 `autoElevate`、
23
+ `hashFallbackUpgrade`、`includeRegistryOnly` 这些 Windows 概念写进去,然后永久存着一批
24
+ 用不到的键,还会被序列化回前端让「设置」看起来像跨平台通用项。
25
+ 新增 `settingAppliesTo(key, family)` 作为唯一判定源:
26
+ - `updateSettings` 丢弃不属于本平台的键(静默,不算错误);
27
+ - `load()` 合并默认值后再裁一次(否则 `defaultSettings()` 的全集又会把它们带回来);
28
+ - 新增 `Store.pruneForeignSettings()`,可主动清掉上个平台遗留的键。
29
+
30
+ ### 修复(独立对抗审计发现,全部先复现再修)
31
+
32
+ 本版在发版前做了一轮独立只读审计,挖出 6 个真实缺陷 —— 其中 1 个是本版新增功能
33
+ 自己引入的回归,另有 1 个从 1.3.x 起就存在。全部用可执行脚本复现过,修完各配回归测试。
34
+
35
+ - **平台归属被「陈旧的 env」带偏,Windows 上会静默丢掉注册表源**(`lib/state.js`)。
36
+ 这是**本版新增裁剪逻辑自己引入的回归**,也是整轮审计里后果最重的一条:
37
+ `platformFamily()` 原先优先读 `state.json` 里存的上次扫描环境,而不是本机平台。
38
+ 只要那份 state 来自另一台机器(同步 `~/.dsh`、漫游配置、恢复备份、换机沿用),
39
+ 在 Windows 上读到陈旧的 `family: "macos"` 就会把 `includeRegistryOnly` 当成外来键删掉;
40
+ 而 engine 对它是**真值判断**,`undefined` = `false`、默认却是 `true` ——
41
+ 于是注册表兜底被静默跳过、registry 条目全被过滤,**界面少显示应用且没有任何警告**
42
+ (正是 1.3.3 修过的那一类静默漏报)。修法两层:
43
+ - `platformFamily()` 改为**只认真实 `process.platform`**,env 仅在真实平台无法判断时兜底;
44
+ - engine 的两处判断改成 `!== false` / `=== false`,与 969/1085 行一致 ——
45
+ 「键不存在」永远等于「按默认开启」,不会被误读成「用户关掉了」。
46
+
47
+ - **「无法判断」被谎报成「已是最新」**(`lib/client.js` 状态列)。非 Homebrew 管理、
48
+ 目录未收录、版本读不出来的应用,`hasUpdate` 恒为 `false`,状态列就渲染「已是最新」——
49
+ 即使 cask 目录里明明有更新(实测 Raycast 已装 1.99.0、目录 2.10.1)。
50
+ 现在这类行显示琥珀色「**不可判断**」并给出悬停原因,不再把「没比较过」说成「已经最新」。
51
+
52
+ - **`useSelfUpdater` 读得到却设不了**(`lib/state.js`)。`engine.js:1013` 读
53
+ `settings.useSelfUpdater !== false`,但它从来没在 `defaultSettings()` 里声明,
54
+ 而 `updateSettings` 只接受已声明的键 —— 结果这个「关闭自带更新器」的逃生开关
55
+ 对用户完全不可达,只能改代码。现已补进默认设置。
56
+
57
+ - **多 `.app` 的 cask 产生重复 item id**(`lib/macos.js`)。一个 cask 声明多个 `.app`
58
+ 是真实存在的(`microsoft-office` 声明 Word/Excel/PowerPoint/Outlook,`box-tools`、
59
+ `boxy-suite` 同理),旧实现给每个 `.app` 都发 `brew:<token>`,于是 4 条 item 同 id →
60
+ 前端 `selected[id]` 勾一个全勾、`stats` 重复计数。现在首个沿用 `brew:<token>`,
61
+ 后续加 bundle 后缀去重。
62
+
63
+ - **哈希配对被 `win32` 别名抢先 → 假失败**(`lib/engine.js` `pickInstallerSha256`)。
64
+ `win32` 是**平台**名不是架构标记,Electron 系文件名把两者拼在一起
65
+ (`App-win32-x64.exe` = 平台 win32 + 架构 x64)。旧实现按对象键顺序先试 x86,
66
+ `win32` 抢先命中 → 拿 x86 的哈希去校验 x64 的包 → 误报「哈希与清单声明不一致」→
67
+ 拒绝安装并白跑一次 winget 兜底。现在强别名(`x64`/`64-bit`/`arm64`…)先全试一遍,
68
+ 弱别名(只有 `win32`)仅在无更强线索时兜底;配对不出时照旧 fail-closed 跳过校验。
69
+
70
+ - **macOS 扫描不响应取消**(`lib/engine.js` `scanMacos`)。最慢的两步
71
+ (枚举 `.app`、cask 目录竞速,单地址 60s)都不收 signal,也没有任何
72
+ `signal.aborted` 抛出 —— 点取消要等它们跑完才响应,界面像卡死。
73
+ Windows 侧 347/380/418 一直有这道检查,macOS 侧漏了。现已补上(枚举后、
74
+ 抓目录前、抓目录后各一次),与 Windows 一致:取消向上抛,不算故障、不写红横幅。
75
+
76
+ ### 测试
77
+
78
+ `unit 217/217`(新增 33 条)、`render 57/57`(新增 6 条)、`deny 12/12`,全部通过。
79
+ 审计同时确认**没有**问题的模块:`proc` / `elevate` / `registry` / `winget` / `version` /
80
+ `platform`,以及 engine 的更新主流程与安装后复核、index 的准入与 CSRF 围栏、
81
+ state 的原子写与并发 `save()`、macos 的竞速与过期缓存兜底。
82
+
5
83
  ## [1.3.7] — 2026-11-02
6
84
 
7
85
  **改名回退**:GitHub 仓库 `19952730389/dsh-appupdater` → **改回 `19952730389/dsh-app-updater`**。
package/README.en.md CHANGED
@@ -1,256 +1,256 @@
1
- # dsh-app-updater — App Updater for DeepSeek Harness
2
-
3
- **English** | [简体中文](README.md)
4
- [![npm](https://img.shields.io/npm/v/dsh-app-updater?style=flat-square&label=npm&color=0891b2&logo=npm)](https://www.npmjs.com/package/dsh-app-updater)
5
- ![license](https://img.shields.io/badge/license-MIT-2f6f4f?style=flat-square)
6
- ![platform](https://img.shields.io/badge/platform-Windows-7da1de?style=flat-square)
7
- ![dsh-plugin](https://img.shields.io/badge/DSH-plugin-263146?style=flat-square)
8
-
9
-
10
- Detect every installed application that has a newer version, then update them
11
- with one click — downloading the **full installer from each vendor's own website** and
12
- installing it silently. **The detected applications are never launched.**
13
- Windows (winget) and macOS (Homebrew), architecture-aware: ARM64 and x64/Intel installers are
14
- chosen to match the machine.
15
-
16
- Where to find it: DeepSeek Harness → Settings → **应用更新 (App Updater)**
17
-
18
- ## Install
19
-
20
- ```bash
21
- # npm package (recommended; also one-click installable from the plugin center)
22
- dsh plugin --profile web add dsh-app-updater
23
- ```
24
-
25
- Without npm, install from source or from the prebuilt tarball:
26
-
27
- ```bash
28
- # source repository
29
- dsh plugin --profile web add github:19952730389/dsh-app-updater
30
-
31
- # prebuilt tgz (see Releases)
32
- dsh plugin --profile web add https://github.com/19952730389/dsh-app-updater/releases/latest/download/dsh-app-updater.tgz
33
- ```
34
-
35
- Restart DSH afterwards; the plugin lives at **Settings → 应用更新**. The Electron-managed
36
- `desktop` profile does not accept `dsh plugin` writes, so link the package into that
37
- profile's `node_modules` and add it to `dsh.profile.bundles` manually.
38
-
39
- ---
40
-
41
- ## What it actually does
42
-
43
- | Step | How |
44
- |---|---|
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
- | 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 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
- | 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
-
50
- Those download URLs really are the vendors' own: `github.com/git-for-windows/…`,
51
- `dl.google.com/dl/chrome/install/…`, `download.cpuid.com/cpu-z/…`, `dl.hdslb.com/…`.
52
- Both the installer and its `.yaml` manifest are kept in the download folder, so the
53
- `InstallerUrl`, `InstallerSha256` and silent switches can be audited afterwards.
54
-
55
- ---
56
-
57
- ## Usage
58
-
59
- 1. Open **Settings → 应用更新**.
60
- 2. Click **刷新检测** (nothing runs in the background; scanning only happens when you ask).
61
- 3. The table sorts updateable apps first. Use the search box and the
62
- **全部 / 仅可更新 / 未收录** segments to narrow it down.
63
- 4. Tick the apps to update (or press **全部更新**), or update a single app from its row.
64
- 5. While updating you get a progress bar, the current app name and a per-item log;
65
- **取消** stops the items that have not started yet.
66
- 6. **打开下载目录** reveals the downloaded vendor installers in Explorer.
67
-
68
- ### Status values
69
-
70
- | Status | Meaning |
71
- |---|---|
72
- | **有新版本** | The catalog reports a higher version; one-click update is available. |
73
- | **已是最新** | No newer version in the catalog. |
74
- | **未收录** | An update exists in the catalog but this plugin cannot drive it (see reasons below). |
75
-
76
- | Reason | Meaning |
77
- |---|---|
78
- | 软件目录未收录 | The app is not in the winget catalog (registry-only software), so its official update cannot be discovered or downloaded. |
79
- | 由应用商店管理 | MSIX / Store app — update it through Microsoft Store. |
80
- | 版本未知 | The installed version cannot be read (winget reports `Unknown`); updating is still allowed. |
81
- | 系统组件 | System component / runtime; not driven by this plugin by default. |
82
-
83
- ---
84
-
85
- ## Settings
86
-
87
- State file: `%USERPROFILE%\.dsh\dsh-app-updater\state.json`
88
-
89
- | Key | Default | Meaning |
90
- |---|---|---|
91
- | `includeStoreApps` | `true` | List store apps too (MSIX on Windows, App Store on macOS; display only). |
92
- | `includeRegistryOnly` | `true` | Windows: fill in apps missing from the winget catalog using the registry (display only). |
93
- | `includeUnmanagedApps` | `true` | macOS: also list apps that Homebrew does not manage (drag-to-Applications installs; display only). |
94
- | `downloadDir` | empty | Custom download folder; empty = `%USERPROFILE%\Downloads\DSH App Updater`. Must be absolute. |
95
- | `keepInstallers` | `true` | Keep the installer after a successful update. |
96
- | `hashFallbackUpgrade` | `true` | Fall back to `winget upgrade` when the download fails. |
97
- | `dryRun` | `false` | **Rehearsal mode**: download the vendor's full installer but never install it. |
98
- | `matchSystemArch` | `true` | Match the installer to the detected system and CPU architecture (Windows passes `--architecture`; macOS is resolved by Homebrew). |
99
- | `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). |
100
-
101
- Everything above is editable from the plugin's own settings panel inside the settings page.
102
- The panel also accepts API calls:
103
-
104
- ```bash
105
- curl -X POST -H "Content-Type: application/json" \
106
- -d '{"confirm":true,"settings":{"dryRun":true}}' \
107
- http://127.0.0.1:<port>/dsh-app-updater/settings
108
- ```
109
-
110
- ---
111
-
112
- ## Safety boundaries
113
-
114
- - **Every route is deny-by-default**: each request first goes through `ctx.connection.admit(request)`
115
- — DSH's Host/Origin fence plus browser-session / API authentication, the same gate the host uses for
116
- its own `/api` routes. Rejected requests get `401` / `403` / `409` with `X-DSH-Admission: denied`.
117
- - **Legacy loopback mode is off by default** (since 1.3.1): when the host has no `connection`
118
- service, requests are no longer trusted just because they come from loopback — they get `403`.
119
- If you really need it on an old host, opt in explicitly, either via plugin config:
120
-
121
- ```yaml
122
- - id: dsh-app-updater
123
- config:
124
- allowLegacyLoopback: true
125
- ```
126
-
127
- or by exporting `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1` before starting DSH. Once enabled, allowed
128
- requests carry `X-DSH-Admission: loopback-legacy-optin` and `X-DSH-Admission-Warning`, the
129
- `admission` field in `state.json` / `health.json` reports `mode: "loopback-legacy-optin"`, the
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.
143
- - **Every write still requires explicit confirmation**: `scan` / `update` / `cancel` /
144
- `settings` / `open-downloads` require `confirm:true`.
145
- - **No guessed silent switches**: the publisher's declared `Silent` switches are preferred;
146
- generic defaults are used only when a manifest is unavailable.
147
- - **Elevation only for installs, and on demand**: install commands that need administrator rights
148
- (MSI packages, installers writing into `Program Files`, the `winget upgrade` fallback) run in an
149
- elevated copy through `Start-Process -Verb RunAs`. With UAC set to "always notify" you get one
150
- prompt per install; with `ConsentPromptBehaviorAdmin = 0` it is completely silent. Reads
151
- (`winget list` / `upgrade --include-unknown` / `--version`) never elevate.
152
- - **A zero exit code is not success**: after installing, the plugin compares the installed version
153
- against the target version and reports a failure when nothing actually changed — machine-scope
154
- installers silently exit 0 without installing anything when they lack administrator rights
155
- (Logitech G HUB used to be reported as "updated" that way).
156
- - **System components** (`.NET`, VC++ runtimes, …) are listed but not updated by default.
157
- - **Only its own directory is written**: state and logs live in `~/.dsh/dsh-app-updater/`.
158
-
159
- ## Requirements
160
-
161
- - **Windows**: `winget.exe` (Microsoft App Installer). Without winget the plugin can still
162
- list registry apps, but cannot tell whether they are up to date.
163
- - **macOS**: no extra dependency is needed to **list** apps (the plugin reads `.app` bundles from
164
- `/Applications` and friends); judging and applying updates needs `brew` ([Homebrew](https://brew.sh)).
165
- Without Homebrew the apps are still listed, with a note that installing Homebrew enables one-click
166
- cask upgrades.
167
- - No third-party npm dependencies: the host half uses Node built-ins only, the browser half
168
- only the host-provided `react`.
169
-
170
- ## Supported systems and architectures
171
-
172
- The plugin detects its own environment at startup and picks the installer that matches it:
173
-
174
- | System | Architecture | Engine | Notes |
175
- |---|---|---|---|
176
- | Windows 10 / 11 | x64 / ARM64 / x86 | **winget** | the detected architecture is passed as `winget --architecture`; if the manifest has no installer for it, the plugin falls back to automatic matching and says so in the log |
177
- | macOS 11+ | Apple Silicon / Intel | **Homebrew casks** | apps are enumerated from `/Applications` and friends, then matched against the cask catalog; Homebrew resolves the right cask for the machine. Without Homebrew the apps are still listed, they just cannot be upgraded in one click |
178
- | anything else | — | none | the scan/update routes answer 400 with the reason |
179
-
180
- The result is shown in the status line and in the settings panel's “运行环境 / Environment” row, e.g.
181
- `Windows 11(build 26300) · x64(AMD64) · winget 1.29.380` or
182
- `macOS 15(Sequoia) · Apple Silicon(ARM64) · Homebrew 4.5.0`.
183
-
184
- The “按本机架构安装包 / match system architecture” switch (on by default) can be turned off to hand
185
- the choice back to winget; Homebrew always resolves for the local architecture.
186
-
187
- > The macOS app enumeration (`/Applications` + `Info.plist`) and cask matching are covered by unit
188
- > tests and were validated against the real `formulae.brew.sh` catalog (7,800+ casks), but the
189
- > macOS **install/update execution** has not been verified on a real Mac — this plugin is developed
190
- > on Windows. Please open an issue if something misbehaves there.
191
- >
192
- > Only the standard application directories are enumerated. Apps installed elsewhere (a custom
193
- > folder, the Desktop, an external volume) are outside the scan scope by design.
194
-
195
- ## HTTP API (host half)
196
-
197
- | Method | Path | Purpose |
198
- |---|---|---|
199
- | GET | `/dsh-app-updater/state.json` | Status, inventory, log, settings |
200
- | GET | `/dsh-app-updater/apps.json` | Inventory and statistics only |
201
- | GET | `/dsh-app-updater/health.json` | Self-check: version, environment, engine, capabilities, route count, data directory |
202
- | POST | `/dsh-app-updater/scan` | `{confirm:true, force:true}` start scanning |
203
- | POST | `/dsh-app-updater/update` | `{confirm:true, ids:[...]}` start the update queue |
204
- | POST | `/dsh-app-updater/cancel` | `{confirm:true}` cancel items not yet started |
205
- | POST | `/dsh-app-updater/settings` | `{confirm:true, settings:{...}}` |
206
- | POST | `/dsh-app-updater/open-downloads` | `{confirm:true}` open the download folder |
207
-
208
- ## Install (from a checkout)
209
-
210
- ```bash
211
- # from a local checkout
212
- dsh plugin --profile <profile> add file:/path/to/dsh-app-updater
213
-
214
- # or as a local link
215
- dsh plugin --profile <profile> add link:/path/to/dsh-app-updater
216
- ```
217
-
218
- Then add `dsh-app-updater` to `dsh.profile.bundles` in the profile's `package.json`
219
- (the bundled `cordis.patch.yml` mounts the host half) and reload DSH.
220
-
221
- ## Uninstall
222
-
223
- 1. Remove `dsh-app-updater` from `dsh.profile.bundles` and from `dependencies` in the profile's `package.json`.
224
- 2. Delete `<profile>/node_modules/dsh-app-updater`.
225
- 3. Restart DSH; the data directory `%USERPROFILE%\.dsh\dsh-app-updater` can be removed as well.
226
-
227
- ## Tests
228
-
229
- ```bash
230
- node test/unit.mjs # 153 checks: table parsing, version compare, manifest & switches, OS/arch detection, Homebrew parsing, macOS app enumeration & cask matching
231
- node test/render.mjs # 51 checks: settings panel (per platform), rehearsal banner, scan-warning banner, column drag, request bodies
232
- node test/deny.mjs # 12 checks: deny-by-default per route when the host has no connection service
233
- node test/harness.mjs --scan # boots the host half against a fake cordis ctx + real HTTP
234
- ```
235
-
236
- > The tests do not depend on whether Homebrew is installed locally: the `findBrew` assertion checks
237
- > existence across the three default prefixes (Apple Silicon / Intel / Linuxbrew), so it stays green
238
- > on a real Mac (1.3.2 and earlier hard-coded `=== null` and was always red there; fixed in 1.3.3).
239
-
240
- ## Layout
241
-
242
- | File | Purpose |
243
- |---|---|
244
- | `lib/index.js` | Host half: HTTP routes, write gate, scan/update scheduling |
245
- | `lib/engine.js` | Inventory merge, vendor installer download, silent install, winget fallback, version re-check |
246
- | `lib/winget.js` | winget discovery/invocation, fixed-width table parsing (CJK-aware) |
247
- | `lib/registry.js` | Registry `Uninstall` enumeration (PowerShell, UTF-8) |
248
- | `lib/version.js` | Windows-style version comparison |
249
- | `lib/state.js` | Settings and state persistence (atomic writes) |
250
- | `lib/proc.js` | Child process runner (timeouts, cancellation, UTF-8) |
251
- | `lib/client.js` | Browser half: the settings page UI |
252
- | `CONTRACT.md` | Host ↔ client interface contract |
253
-
254
- ## License
255
-
1
+ # dsh-app-updater — App Updater for DeepSeek Harness
2
+
3
+ **English** | [简体中文](README.md)
4
+ [![npm](https://img.shields.io/npm/v/dsh-app-updater?style=flat-square&label=npm&color=0891b2&logo=npm)](https://www.npmjs.com/package/dsh-app-updater)
5
+ ![license](https://img.shields.io/badge/license-MIT-2f6f4f?style=flat-square)
6
+ ![platform](https://img.shields.io/badge/platform-Windows-7da1de?style=flat-square)
7
+ ![dsh-plugin](https://img.shields.io/badge/DSH-plugin-263146?style=flat-square)
8
+
9
+
10
+ Detect every installed application that has a newer version, then update them
11
+ with one click — downloading the **full installer from each vendor's own website** and
12
+ installing it silently. **The detected applications are never launched.**
13
+ Windows (winget) and macOS (Homebrew), architecture-aware: ARM64 and x64/Intel installers are
14
+ chosen to match the machine.
15
+
16
+ Where to find it: DeepSeek Harness → Settings → **应用更新 (App Updater)**
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # npm package (recommended; also one-click installable from the plugin center)
22
+ dsh plugin --profile web add dsh-app-updater
23
+ ```
24
+
25
+ Without npm, install from source or from the prebuilt tarball:
26
+
27
+ ```bash
28
+ # source repository
29
+ dsh plugin --profile web add github:19952730389/dsh-app-updater
30
+
31
+ # prebuilt tgz (see Releases)
32
+ dsh plugin --profile web add https://github.com/19952730389/dsh-app-updater/releases/latest/download/dsh-app-updater.tgz
33
+ ```
34
+
35
+ Restart DSH afterwards; the plugin lives at **Settings → 应用更新**. The Electron-managed
36
+ `desktop` profile does not accept `dsh plugin` writes, so link the package into that
37
+ profile's `node_modules` and add it to `dsh.profile.bundles` manually.
38
+
39
+ ---
40
+
41
+ ## What it actually does
42
+
43
+ | Step | How |
44
+ |---|---|
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
+ | 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 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
+ | 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
+
50
+ Those download URLs really are the vendors' own: `github.com/git-for-windows/…`,
51
+ `dl.google.com/dl/chrome/install/…`, `download.cpuid.com/cpu-z/…`, `dl.hdslb.com/…`.
52
+ Both the installer and its `.yaml` manifest are kept in the download folder, so the
53
+ `InstallerUrl`, `InstallerSha256` and silent switches can be audited afterwards.
54
+
55
+ ---
56
+
57
+ ## Usage
58
+
59
+ 1. Open **Settings → 应用更新**.
60
+ 2. Click **刷新检测** (nothing runs in the background; scanning only happens when you ask).
61
+ 3. The table sorts updateable apps first. Use the search box and the
62
+ **全部 / 仅可更新 / 未收录** segments to narrow it down.
63
+ 4. Tick the apps to update (or press **全部更新**), or update a single app from its row.
64
+ 5. While updating you get a progress bar, the current app name and a per-item log;
65
+ **取消** stops the items that have not started yet.
66
+ 6. **打开下载目录** reveals the downloaded vendor installers in Explorer.
67
+
68
+ ### Status values
69
+
70
+ | Status | Meaning |
71
+ |---|---|
72
+ | **有新版本** | The catalog reports a higher version; one-click update is available. |
73
+ | **已是最新** | No newer version in the catalog. |
74
+ | **未收录** | An update exists in the catalog but this plugin cannot drive it (see reasons below). |
75
+
76
+ | Reason | Meaning |
77
+ |---|---|
78
+ | 软件目录未收录 | The app is not in the winget catalog (registry-only software), so its official update cannot be discovered or downloaded. |
79
+ | 由应用商店管理 | MSIX / Store app — update it through Microsoft Store. |
80
+ | 版本未知 | The installed version cannot be read (winget reports `Unknown`); updating is still allowed. |
81
+ | 系统组件 | System component / runtime; not driven by this plugin by default. |
82
+
83
+ ---
84
+
85
+ ## Settings
86
+
87
+ State file: `%USERPROFILE%\.dsh\dsh-app-updater\state.json`
88
+
89
+ | Key | Default | Meaning |
90
+ |---|---|---|
91
+ | `includeStoreApps` | `true` | List store apps too (MSIX on Windows, App Store on macOS; display only). |
92
+ | `includeRegistryOnly` | `true` | Windows: fill in apps missing from the winget catalog using the registry (display only). |
93
+ | `includeUnmanagedApps` | `true` | macOS: also list apps that Homebrew does not manage (drag-to-Applications installs; display only). |
94
+ | `downloadDir` | empty | Custom download folder; empty = `%USERPROFILE%\Downloads\DSH App Updater`. Must be absolute. |
95
+ | `keepInstallers` | `true` | Keep the installer after a successful update. |
96
+ | `hashFallbackUpgrade` | `true` | Fall back to `winget upgrade` when the download fails. |
97
+ | `dryRun` | `false` | **Rehearsal mode**: download the vendor's full installer but never install it. |
98
+ | `matchSystemArch` | `true` | Match the installer to the detected system and CPU architecture (Windows passes `--architecture`; macOS is resolved by Homebrew). |
99
+ | `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). |
100
+
101
+ Everything above is editable from the plugin's own settings panel inside the settings page.
102
+ The panel also accepts API calls:
103
+
104
+ ```bash
105
+ curl -X POST -H "Content-Type: application/json" \
106
+ -d '{"confirm":true,"settings":{"dryRun":true}}' \
107
+ http://127.0.0.1:<port>/dsh-app-updater/settings
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Safety boundaries
113
+
114
+ - **Every route is deny-by-default**: each request first goes through `ctx.connection.admit(request)`
115
+ — DSH's Host/Origin fence plus browser-session / API authentication, the same gate the host uses for
116
+ its own `/api` routes. Rejected requests get `401` / `403` / `409` with `X-DSH-Admission: denied`.
117
+ - **Legacy loopback mode is off by default** (since 1.3.1): when the host has no `connection`
118
+ service, requests are no longer trusted just because they come from loopback — they get `403`.
119
+ If you really need it on an old host, opt in explicitly, either via plugin config:
120
+
121
+ ```yaml
122
+ - id: dsh-app-updater
123
+ config:
124
+ allowLegacyLoopback: true
125
+ ```
126
+
127
+ or by exporting `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1` before starting DSH. Once enabled, allowed
128
+ requests carry `X-DSH-Admission: loopback-legacy-optin` and `X-DSH-Admission-Warning`, the
129
+ `admission` field in `state.json` / `health.json` reports `mode: "loopback-legacy-optin"`, the
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.
143
+ - **Every write still requires explicit confirmation**: `scan` / `update` / `cancel` /
144
+ `settings` / `open-downloads` require `confirm:true`.
145
+ - **No guessed silent switches**: the publisher's declared `Silent` switches are preferred;
146
+ generic defaults are used only when a manifest is unavailable.
147
+ - **Elevation only for installs, and on demand**: install commands that need administrator rights
148
+ (MSI packages, installers writing into `Program Files`, the `winget upgrade` fallback) run in an
149
+ elevated copy through `Start-Process -Verb RunAs`. With UAC set to "always notify" you get one
150
+ prompt per install; with `ConsentPromptBehaviorAdmin = 0` it is completely silent. Reads
151
+ (`winget list` / `upgrade --include-unknown` / `--version`) never elevate.
152
+ - **A zero exit code is not success**: after installing, the plugin compares the installed version
153
+ against the target version and reports a failure when nothing actually changed — machine-scope
154
+ installers silently exit 0 without installing anything when they lack administrator rights
155
+ (Logitech G HUB used to be reported as "updated" that way).
156
+ - **System components** (`.NET`, VC++ runtimes, …) are listed but not updated by default.
157
+ - **Only its own directory is written**: state and logs live in `~/.dsh/dsh-app-updater/`.
158
+
159
+ ## Requirements
160
+
161
+ - **Windows**: `winget.exe` (Microsoft App Installer). Without winget the plugin can still
162
+ list registry apps, but cannot tell whether they are up to date.
163
+ - **macOS**: no extra dependency is needed to **list** apps (the plugin reads `.app` bundles from
164
+ `/Applications` and friends); judging and applying updates needs `brew` ([Homebrew](https://brew.sh)).
165
+ Without Homebrew the apps are still listed, with a note that installing Homebrew enables one-click
166
+ cask upgrades.
167
+ - No third-party npm dependencies: the host half uses Node built-ins only, the browser half
168
+ only the host-provided `react`.
169
+
170
+ ## Supported systems and architectures
171
+
172
+ The plugin detects its own environment at startup and picks the installer that matches it:
173
+
174
+ | System | Architecture | Engine | Notes |
175
+ |---|---|---|---|
176
+ | Windows 10 / 11 | x64 / ARM64 / x86 | **winget** | the detected architecture is passed as `winget --architecture`; if the manifest has no installer for it, the plugin falls back to automatic matching and says so in the log |
177
+ | macOS 11+ | Apple Silicon / Intel | **Homebrew casks** | apps are enumerated from `/Applications` and friends, then matched against the cask catalog; Homebrew resolves the right cask for the machine. Without Homebrew the apps are still listed, they just cannot be upgraded in one click |
178
+ | anything else | — | none | the scan/update routes answer 400 with the reason |
179
+
180
+ The result is shown in the status line and in the settings panel's “运行环境 / Environment” row, e.g.
181
+ `Windows 11(build 26300) · x64(AMD64) · winget 1.29.380` or
182
+ `macOS 15(Sequoia) · Apple Silicon(ARM64) · Homebrew 4.5.0`.
183
+
184
+ The “按本机架构安装包 / match system architecture” switch (on by default) can be turned off to hand
185
+ the choice back to winget; Homebrew always resolves for the local architecture.
186
+
187
+ > The macOS app enumeration (`/Applications` + `Info.plist`) and cask matching are covered by unit
188
+ > tests and were validated against the real `formulae.brew.sh` catalog (7,800+ casks), but the
189
+ > macOS **install/update execution** has not been verified on a real Mac — this plugin is developed
190
+ > on Windows. Please open an issue if something misbehaves there.
191
+ >
192
+ > Only the standard application directories are enumerated. Apps installed elsewhere (a custom
193
+ > folder, the Desktop, an external volume) are outside the scan scope by design.
194
+
195
+ ## HTTP API (host half)
196
+
197
+ | Method | Path | Purpose |
198
+ |---|---|---|
199
+ | GET | `/dsh-app-updater/state.json` | Status, inventory, log, settings |
200
+ | GET | `/dsh-app-updater/apps.json` | Inventory and statistics only |
201
+ | GET | `/dsh-app-updater/health.json` | Self-check: version, environment, engine, capabilities, route count, data directory |
202
+ | POST | `/dsh-app-updater/scan` | `{confirm:true, force:true}` start scanning |
203
+ | POST | `/dsh-app-updater/update` | `{confirm:true, ids:[...]}` start the update queue |
204
+ | POST | `/dsh-app-updater/cancel` | `{confirm:true}` cancel items not yet started |
205
+ | POST | `/dsh-app-updater/settings` | `{confirm:true, settings:{...}}` |
206
+ | POST | `/dsh-app-updater/open-downloads` | `{confirm:true}` open the download folder |
207
+
208
+ ## Install (from a checkout)
209
+
210
+ ```bash
211
+ # from a local checkout
212
+ dsh plugin --profile <profile> add file:/path/to/dsh-app-updater
213
+
214
+ # or as a local link
215
+ dsh plugin --profile <profile> add link:/path/to/dsh-app-updater
216
+ ```
217
+
218
+ Then add `dsh-app-updater` to `dsh.profile.bundles` in the profile's `package.json`
219
+ (the bundled `cordis.patch.yml` mounts the host half) and reload DSH.
220
+
221
+ ## Uninstall
222
+
223
+ 1. Remove `dsh-app-updater` from `dsh.profile.bundles` and from `dependencies` in the profile's `package.json`.
224
+ 2. Delete `<profile>/node_modules/dsh-app-updater`.
225
+ 3. Restart DSH; the data directory `%USERPROFILE%\.dsh\dsh-app-updater` can be removed as well.
226
+
227
+ ## Tests
228
+
229
+ ```bash
230
+ node test/unit.mjs # 153 checks: table parsing, version compare, manifest & switches, OS/arch detection, Homebrew parsing, macOS app enumeration & cask matching
231
+ node test/render.mjs # 51 checks: settings panel (per platform), rehearsal banner, scan-warning banner, column drag, request bodies
232
+ node test/deny.mjs # 12 checks: deny-by-default per route when the host has no connection service
233
+ node test/harness.mjs --scan # boots the host half against a fake cordis ctx + real HTTP
234
+ ```
235
+
236
+ > The tests do not depend on whether Homebrew is installed locally: the `findBrew` assertion checks
237
+ > existence across the three default prefixes (Apple Silicon / Intel / Linuxbrew), so it stays green
238
+ > on a real Mac (1.3.2 and earlier hard-coded `=== null` and was always red there; fixed in 1.3.3).
239
+
240
+ ## Layout
241
+
242
+ | File | Purpose |
243
+ |---|---|
244
+ | `lib/index.js` | Host half: HTTP routes, write gate, scan/update scheduling |
245
+ | `lib/engine.js` | Inventory merge, vendor installer download, silent install, winget fallback, version re-check |
246
+ | `lib/winget.js` | winget discovery/invocation, fixed-width table parsing (CJK-aware) |
247
+ | `lib/registry.js` | Registry `Uninstall` enumeration (PowerShell, UTF-8) |
248
+ | `lib/version.js` | Windows-style version comparison |
249
+ | `lib/state.js` | Settings and state persistence (atomic writes) |
250
+ | `lib/proc.js` | Child process runner (timeouts, cancellation, UTF-8) |
251
+ | `lib/client.js` | Browser half: the settings page UI |
252
+ | `CONTRACT.md` | Host ↔ client interface contract |
253
+
254
+ ## License
255
+
256
256
  MIT