dsh-app-updater 1.3.1 → 1.3.2
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 +43 -0
- package/CONTRACT.md +9 -4
- package/README.en.md +18 -10
- package/README.md +15 -9
- package/lib/client.js +48 -4
- package/lib/engine.js +61 -65
- package/lib/macos.js +409 -0
- package/lib/platform.js +7 -5
- package/lib/state.js +2 -0
- package/package.json +1 -1
- package/test/render.mjs +55 -1
- package/test/unit.mjs +129 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.3.2] — 2026-11-01
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **macOS 上「未检测到任何已安装应用」**(用户实测报告):1.3.0/1.3.1 的 macOS 扫描只执行
|
|
10
|
+
`brew list --cask --versions`,也就是**只有通过 Homebrew 装过的应用才会出现在列表里**。
|
|
11
|
+
一台正常使用的 Mac 上绝大多数应用是拖 DMG 装的,于是该命令返回空 → 界面显示「未检测到
|
|
12
|
+
任何已安装应用」,日志里是「Homebrew 报告 0 个已安装 cask 应用」。这不是「没有应用」,
|
|
13
|
+
而是**根本没有枚举应用**。现在(`lib/macos.js`):
|
|
14
|
+
- 枚举标准应用目录里的 `.app`:`/Applications`、`/Applications/Utilities`、
|
|
15
|
+
`/System/Applications`(及其 `Utilities`)、`~/Applications`,用 `plutil` 读每个
|
|
16
|
+
bundle 的 `Info.plist` 取显示名与版本(读不到时退回 `defaults read`);
|
|
17
|
+
- 抓取 Homebrew 的 cask 目录(`formulae.brew.sh/api/cask.json`,本地缓存 24 小时)把
|
|
18
|
+
`.app` 反查到 cask token,从而知道**官网最新版本**;
|
|
19
|
+
- 只有**确实由 Homebrew 管理**的应用才标记为可一键更新;其余应用如实列出并标注原因
|
|
20
|
+
(`not-managed` 非 Homebrew 管理 / `store-managed` 由 App Store 管理 /
|
|
21
|
+
`system-component` 系统组件),绝不假装能更新。
|
|
22
|
+
- **同一个 `.app` 被多个 cask 声明时匹配错乱**:真实目录里 `iterm2` 与 `iterm2@nightly`
|
|
23
|
+
都声明 `iTerm.app`,旧写法「后写覆盖」会让匹配结果随目录顺序漂移(实测会把同一个应用
|
|
24
|
+
列出两次)。现在保留全部候选,由 `pickCask` 按「已装 > 非弃用/停用 > 主 token >
|
|
25
|
+
token 与应用名相等」挑选。
|
|
26
|
+
- **没有 Homebrew 也能扫描**:`capabilitiesFor` 不再把「macOS 无 Homebrew」判成不可扫描 ——
|
|
27
|
+
应用照样列出来,只是无法判断/驱动更新,提示改为「安装 Homebrew 后可一键升级 cask 应用」。
|
|
28
|
+
- **设置面板按平台显示**:macOS 上不再出现「自动提权安装」「下载失败时回退 winget」
|
|
29
|
+
「包含应用商店应用」(Windows 概念),改为 macOS 专属的「包含 App Store 应用」与
|
|
30
|
+
「包含非 Homebrew 应用」(新增设置项 `includeUnmanagedApps`,默认开启)。
|
|
31
|
+
- **空状态文案分平台**:macOS 下列表为空时提示去看运行日志里的「枚举应用目录」记录,
|
|
32
|
+
而不是干巴巴一句「未检测到任何已安装应用」。
|
|
33
|
+
|
|
34
|
+
### 新增
|
|
35
|
+
|
|
36
|
+
- **`lib/macos.js`**:应用目录枚举、`Info.plist` 解析、cask 目录缓存与解析、cask 匹配、
|
|
37
|
+
列表合并(纯函数 `buildMacosItems` 便于单测)。
|
|
38
|
+
- 日志更细:分别记录「枚举到多少个 .app」「Homebrew 报告多少个 cask」「读取 cask 目录结果」,
|
|
39
|
+
macOS 上再出现异常时一眼能看出断在哪一步。
|
|
40
|
+
|
|
41
|
+
### 测试
|
|
42
|
+
|
|
43
|
+
- `test/unit.mjs` 152 项(新增 macOS 应用枚举 20 项、同名 cask 挑选 6 项、cask 目录解析 6 项);
|
|
44
|
+
用**真实** `formulae.brew.sh/api/cask.json`(7824 个 cask)验证过匹配与去重。
|
|
45
|
+
- `test/render.mjs` 44 项(新增 macOS 设置面板 6 项、macOS 空状态文案 2 项)。
|
|
46
|
+
- `test/deny.mjs` 12 项不变。
|
|
47
|
+
|
|
5
48
|
## [1.3.1] — 2026-11-01
|
|
6
49
|
|
|
7
50
|
### 安全
|
package/CONTRACT.md
CHANGED
|
@@ -76,20 +76,25 @@ dsh-app-updater/
|
|
|
76
76
|
|
|
77
77
|
```jsonc
|
|
78
78
|
{
|
|
79
|
-
"id": "Google.Chrome", // winget id;注册表项 "reg:<key>";macOS "brew:<cask token>"
|
|
79
|
+
"id": "Google.Chrome", // winget id;注册表项 "reg:<key>";macOS 由 Homebrew 管理的是 "brew:<cask token>",其余 "macapp:<bundle id>"
|
|
80
80
|
"name": "Google Chrome",
|
|
81
81
|
"version": "155.0.8059.85", // 已安装版本,未知时 ""
|
|
82
82
|
"available": "156.0.8078.48", // 可升级到的版本,无更新时 ""
|
|
83
83
|
"publisher": "Google LLC",
|
|
84
|
-
"source": "winget", // winget | msstore | registry | homebrew
|
|
84
|
+
"source": "winget", // winget | msstore | registry | homebrew | appstore | app
|
|
85
85
|
"installLocation": "C:\\Program Files\\Google\\Chrome\\Application",
|
|
86
86
|
"hasUpdate": true,
|
|
87
87
|
"updateSupported": true, // 本插件能否驱动更新
|
|
88
|
-
"reason": null, // null | "not-in-catalog" | "unknown-version" | "pinned" | "store-managed" | "system-component"
|
|
89
|
-
"kind": "exe" // exe | msi | msix | inno | cask | null(未知)
|
|
88
|
+
"reason": null, // null | "not-in-catalog" | "not-managed" | "unknown-version" | "pinned" | "store-managed" | "system-component"
|
|
89
|
+
"kind": "exe" // exe | msi | msix | inno | cask | app | null(未知)
|
|
90
90
|
}
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
`reason` 里的 `not-managed` 是 macOS 专用:应用在标准目录里枚举到了,但不是由 Homebrew 管理
|
|
94
|
+
(拖 DMG / 手动安装),所以能列出、不能一键更新。`source` 的 `app` / `appstore` 同理只出现在
|
|
95
|
+
macOS。macOS 的扫描范围是 `/Applications`、`/Applications/Utilities`、`/System/Applications`
|
|
96
|
+
(及其 `Utilities`)与 `~/Applications`;装在别处的应用不在范围内。
|
|
97
|
+
|
|
93
98
|
### POST /dsh-app-updater/scan
|
|
94
99
|
|
|
95
100
|
body `{ "confirm": true, "force": true }` → `{ "ok": true, "started": true }`(异步;结果看 state.json / apps.json)
|
package/README.en.md
CHANGED
|
@@ -88,8 +88,9 @@ State file: `%USERPROFILE%\.dsh\dsh-app-updater\state.json`
|
|
|
88
88
|
|
|
89
89
|
| Key | Default | Meaning |
|
|
90
90
|
|---|---|---|
|
|
91
|
-
| `includeStoreApps` | `true` | List
|
|
92
|
-
| `includeRegistryOnly` | `true` |
|
|
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). |
|
|
93
94
|
| `downloadDir` | empty | Custom download folder; empty = `%USERPROFILE%\Downloads\DSH App Updater`. Must be absolute. |
|
|
94
95
|
| `keepInstallers` | `true` | Keep the installer after a successful update. |
|
|
95
96
|
| `hashFallbackUpgrade` | `true` | Fall back to `winget upgrade` when the download fails. |
|
|
@@ -147,8 +148,10 @@ curl -X POST -H "Content-Type: application/json" \
|
|
|
147
148
|
|
|
148
149
|
- **Windows**: `winget.exe` (Microsoft App Installer). Without winget the plugin can still
|
|
149
150
|
list registry apps, but cannot tell whether they are up to date.
|
|
150
|
-
- **macOS**:
|
|
151
|
-
|
|
151
|
+
- **macOS**: no extra dependency is needed to **list** apps (the plugin reads `.app` bundles from
|
|
152
|
+
`/Applications` and friends); judging and applying updates needs `brew` ([Homebrew](https://brew.sh)).
|
|
153
|
+
Without Homebrew the apps are still listed, with a note that installing Homebrew enables one-click
|
|
154
|
+
cask upgrades.
|
|
152
155
|
- No third-party npm dependencies: the host half uses Node built-ins only, the browser half
|
|
153
156
|
only the host-provided `react`.
|
|
154
157
|
|
|
@@ -159,7 +162,7 @@ The plugin detects its own environment at startup and picks the installer that m
|
|
|
159
162
|
| System | Architecture | Engine | Notes |
|
|
160
163
|
|---|---|---|---|
|
|
161
164
|
| 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 |
|
|
162
|
-
| macOS 11+ | Apple Silicon / Intel | **Homebrew casks** | Homebrew
|
|
165
|
+
| 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 |
|
|
163
166
|
| anything else | — | none | the scan/update routes answer 400 with the reason |
|
|
164
167
|
|
|
165
168
|
The result is shown in the status line and in the settings panel's “运行环境 / Environment” row, e.g.
|
|
@@ -169,9 +172,13 @@ The result is shown in the status line and in the settings panel's “运行环
|
|
|
169
172
|
The “按本机架构安装包 / match system architecture” switch (on by default) can be turned off to hand
|
|
170
173
|
the choice back to winget; Homebrew always resolves for the local architecture.
|
|
171
174
|
|
|
172
|
-
> The macOS
|
|
173
|
-
>
|
|
174
|
-
>
|
|
175
|
+
> The macOS app enumeration (`/Applications` + `Info.plist`) and cask matching are covered by unit
|
|
176
|
+
> tests and were validated against the real `formulae.brew.sh` catalog (7,800+ casks), but the
|
|
177
|
+
> macOS **install/update execution** has not been verified on a real Mac — this plugin is developed
|
|
178
|
+
> on Windows. Please open an issue if something misbehaves there.
|
|
179
|
+
>
|
|
180
|
+
> Only the standard application directories are enumerated. Apps installed elsewhere (a custom
|
|
181
|
+
> folder, the Desktop, an external volume) are outside the scan scope by design.
|
|
175
182
|
|
|
176
183
|
## HTTP API (host half)
|
|
177
184
|
|
|
@@ -208,8 +215,9 @@ Then add `dsh-app-updater` to `dsh.profile.bundles` in the profile's `package.js
|
|
|
208
215
|
## Tests
|
|
209
216
|
|
|
210
217
|
```bash
|
|
211
|
-
node test/unit.mjs #
|
|
212
|
-
node test/render.mjs #
|
|
218
|
+
node test/unit.mjs # 152 checks: table parsing, version compare, manifest & switches, OS/arch detection, Homebrew parsing, macOS app enumeration & cask matching
|
|
219
|
+
node test/render.mjs # 44 checks: settings panel (per platform), rehearsal banner, column drag, request bodies
|
|
220
|
+
node test/deny.mjs # 12 checks: deny-by-default per route when the host has no connection service
|
|
213
221
|
node test/harness.mjs --scan # boots the host half against a fake cordis ctx + real HTTP
|
|
214
222
|
```
|
|
215
223
|
|
package/README.md
CHANGED
|
@@ -93,9 +93,10 @@ dsh plugin --profile web add https://github.com/19952730389/dsh-app-updater/rele
|
|
|
93
93
|
|
|
94
94
|
| 键 | 默认 | 说明 |
|
|
95
95
|
|---|---|---|
|
|
96
|
-
| `includeStoreApps` | `true` |
|
|
97
|
-
| `includeRegistryOnly` | `true` |
|
|
98
|
-
| `
|
|
96
|
+
| `includeStoreApps` | `true` | 是否把商店应用列进来(Windows 是 MSIX、macOS 是 App Store 应用;只展示,不会被更新)。 |
|
|
97
|
+
| `includeRegistryOnly` | `true` | Windows:是否用注册表补齐 winget 未收录的应用(只展示,不能更新)。 |
|
|
98
|
+
| `includeUnmanagedApps` | `true` | macOS:是否把非 Homebrew 管理(拖 DMG / 手动装)的应用也列出来(只展示,不能更新)。 |
|
|
99
|
+
| `downloadDir` | 空 | 自定义安装包下载目录;空 = `%USERPROFILE%\Downloads\DSH App Updater`(macOS 为 `~/Downloads/DSH App Updater`)。 |
|
|
99
100
|
| `keepInstallers` | `true` | 更新后保留安装包;`false` 则在安装完成后删掉该应用刚下载的包。 |
|
|
100
101
|
| `hashFallbackUpgrade` | `true` | 下载失败时退回 `winget upgrade` 兜底。 |
|
|
101
102
|
| `dryRun` | `false` | **演练模式**:只下载官网全量安装包、不执行安装。用来先看会装什么。 |
|
|
@@ -154,7 +155,7 @@ curl -X POST -H "Content-Type: application/json" \
|
|
|
154
155
|
## 依赖
|
|
155
156
|
|
|
156
157
|
- **Windows**:`winget.exe`(Microsoft「应用安装程序 / App Installer」)。没装 winget 时插件仍能列出注册表里的应用,但无法判断是否有新版本。
|
|
157
|
-
- **macOS
|
|
158
|
+
- **macOS**:无需额外依赖即可**列出**应用(直接读 `/Applications` 等目录里的 `.app`);要判断并执行更新需要 `brew`([Homebrew](https://brew.sh))。没装 Homebrew 时会照常列出应用,并说明「安装 Homebrew 后可一键升级 cask 应用」。
|
|
158
159
|
- 不需要任何第三方 npm 依赖;宿主半身只用 Node 内置模块,浏览器半身只用宿主提供的 `react`。
|
|
159
160
|
|
|
160
161
|
## 接口(宿主半身)
|
|
@@ -189,8 +190,9 @@ dsh plugin --profile <profile> add link:/path/to/dsh-app-updater
|
|
|
189
190
|
## 测试
|
|
190
191
|
|
|
191
192
|
```bash
|
|
192
|
-
node test/unit.mjs #
|
|
193
|
-
node test/render.mjs #
|
|
193
|
+
node test/unit.mjs # 152 项:定宽表格解析、版本比较、清单与静默参数、系统/架构识别、Homebrew 解析、macOS 应用枚举与 cask 匹配
|
|
194
|
+
node test/render.mjs # 44 项:设置面板(按平台)、演练提示、列宽拖拽、请求体
|
|
195
|
+
node test/deny.mjs # 12 项:无 connection 服务时逐路由验证默认拒绝
|
|
194
196
|
node test/harness.mjs --scan # 用假 cordis ctx + 真 HTTP 把宿主半身跑起来
|
|
195
197
|
```
|
|
196
198
|
|
|
@@ -201,7 +203,7 @@ node test/harness.mjs --scan # 用假 cordis ctx + 真 HTTP 把宿主半身
|
|
|
201
203
|
| 系统 | 架构 | 更新引擎 | 说明 |
|
|
202
204
|
|---|---|---|---|
|
|
203
205
|
| Windows 10 / 11 | x64 / ARM64 / x86 | **winget** | 把识别到的架构传给 `winget --architecture`;清单没有该架构的包时自动退回自动匹配并记录日志 |
|
|
204
|
-
| macOS 11+ | Apple Silicon / Intel | **Homebrew cask** | Homebrew 自己按本机架构取 cask
|
|
206
|
+
| macOS 11+ | Apple Silicon / Intel | **Homebrew cask** | 应用从 `/Applications` 等目录枚举,再按 cask 目录匹配;Homebrew 自己按本机架构取 cask。没装 Homebrew 时仍能列出应用,只是不能一键更新 |
|
|
205
207
|
| 其它 | — | 无 | 扫描/更新接口返回 400 并说明原因 |
|
|
206
208
|
|
|
207
209
|
识别结果会显示在状态行与设置面板的「运行环境」里,例如
|
|
@@ -219,8 +221,12 @@ UAC 为「始终通知」时每次安装会弹一次确认;把 DSH 本身以
|
|
|
219
221
|
Edge 走它自带的更新服务:winget 目录里的 `Microsoft.Edge` 是企业版 MSI,与本机消费版的
|
|
220
222
|
安装技术不匹配(`0x8A15008E`),winget 会直接拒绝。
|
|
221
223
|
|
|
222
|
-
> macOS
|
|
223
|
-
>
|
|
224
|
+
> macOS 的应用枚举(`/Applications` 等目录 + `Info.plist`)与 cask 匹配逻辑有单测覆盖,
|
|
225
|
+
> 并用真实的 `formulae.brew.sh` 目录(7800+ cask)验证过匹配与去重;
|
|
226
|
+
> 但本插件主要在 Windows 上开发,macOS 上的**安装/更新执行**没有真机验证过,遇到问题请开 Issue。
|
|
227
|
+
>
|
|
228
|
+
> macOS 上只枚举标准应用目录。应用如果装在别处(例如 `~/Desktop` 或自定义目录),
|
|
229
|
+
> 本插件看不到它 —— 这不是漏扫,是扫描范围之外。
|
|
224
230
|
|
|
225
231
|
## 卸载
|
|
226
232
|
|
package/lib/client.js
CHANGED
|
@@ -34,7 +34,9 @@ window.__ModuleLoader__.load({
|
|
|
34
34
|
const MIN_COL_PX = 48;
|
|
35
35
|
const MAX_COL_PX = 640;
|
|
36
36
|
|
|
37
|
-
/* 设置项 →
|
|
37
|
+
/* 设置项 → 中文标签与说明。
|
|
38
|
+
`only` 限定平台:跨平台的开关不写,平台专属的开关只有对应系统才显示,
|
|
39
|
+
避免 macOS 上出现「自动提权安装」这种 Windows 概念。 */
|
|
38
40
|
const TOGGLES = [
|
|
39
41
|
{
|
|
40
42
|
key: "keepInstallers",
|
|
@@ -56,26 +58,49 @@ window.__ModuleLoader__.load({
|
|
|
56
58
|
key: "autoElevate",
|
|
57
59
|
label: "自动提权安装",
|
|
58
60
|
hint: "需要管理员的安装包(MSI、写入 Program Files)改由管理员上下文执行;UAC 开启时每次安装会弹一次确认",
|
|
61
|
+
only: "windows",
|
|
59
62
|
},
|
|
60
63
|
{
|
|
61
64
|
key: "hashFallbackUpgrade",
|
|
62
65
|
label: "下载失败时回退 winget",
|
|
63
66
|
hint: "官网包哈希与目录记录不一致时,改用 winget 安装同源全量包",
|
|
67
|
+
only: "windows",
|
|
64
68
|
},
|
|
65
69
|
{
|
|
66
70
|
key: "includeStoreApps",
|
|
67
71
|
label: "包含应用商店应用",
|
|
68
72
|
hint: "商店 / MSIX 应用只列出,更新仍由 Microsoft Store 负责",
|
|
73
|
+
only: "windows",
|
|
69
74
|
},
|
|
70
75
|
{
|
|
71
76
|
key: "includeRegistryOnly",
|
|
72
77
|
label: "包含目录未收录应用",
|
|
73
78
|
hint: "用注册表补齐 winget 目录未收录的软件(只列出,无法自动更新)",
|
|
79
|
+
only: "windows",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "includeStoreApps",
|
|
83
|
+
label: "包含 App Store 应用",
|
|
84
|
+
hint: "App Store 应用只列出,升级仍由 App Store 负责",
|
|
85
|
+
only: "macos",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "includeUnmanagedApps",
|
|
89
|
+
label: "包含非 Homebrew 应用",
|
|
90
|
+
hint: "把拖 DMG / 手动安装的应用也列出来(只列出,无法自动更新)",
|
|
91
|
+
only: "macos",
|
|
74
92
|
},
|
|
75
93
|
];
|
|
76
94
|
|
|
95
|
+
/** 当前平台该显示的开关(未知平台按 Windows 显示,保持兼容)。 */
|
|
96
|
+
function togglesFor(family) {
|
|
97
|
+
const fam = family === "macos" ? "macos" : "windows";
|
|
98
|
+
return TOGGLES.filter((t) => !t.only || t.only === fam);
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
const REASON_TEXT = {
|
|
78
102
|
"not-in-catalog": "软件目录未收录",
|
|
103
|
+
"not-managed": "非 Homebrew 管理",
|
|
79
104
|
"unknown-version": "版本未知",
|
|
80
105
|
"system-component": "系统组件",
|
|
81
106
|
pinned: "版本被固定",
|
|
@@ -87,6 +112,8 @@ window.__ModuleLoader__.load({
|
|
|
87
112
|
msstore: "msstore",
|
|
88
113
|
registry: "registry",
|
|
89
114
|
homebrew: "homebrew",
|
|
115
|
+
appstore: "App Store",
|
|
116
|
+
app: "应用",
|
|
90
117
|
};
|
|
91
118
|
|
|
92
119
|
/** 引擎显示名 */
|
|
@@ -170,6 +197,8 @@ window.__ModuleLoader__.load({
|
|
|
170
197
|
".appupd-badge-msstore{border-color:rgba(52,211,153,.55);color:#34d399;}",
|
|
171
198
|
".appupd-badge-registry{border-color:rgba(148,163,184,.5);}",
|
|
172
199
|
".appupd-badge-homebrew{border-color:rgba(251,191,36,.55);color:#fbbf24;}",
|
|
200
|
+
".appupd-badge-appstore{border-color:rgba(52,211,153,.55);color:#34d399;}",
|
|
201
|
+
".appupd-badge-app{border-color:rgba(148,163,184,.5);}",
|
|
173
202
|
".appupd-envline{font-size:12px;color:var(--dsw-alias-label-primary,#f3f4f6);word-break:break-word;}",
|
|
174
203
|
".appupd-state-new{font-weight:600;color:var(--dsw-alias-brand-primary,#4f8cff);white-space:nowrap;}",
|
|
175
204
|
".appupd-state-ok{color:#34d399;white-space:nowrap;}",
|
|
@@ -634,6 +663,8 @@ window.__ModuleLoader__.load({
|
|
|
634
663
|
(state && state.envSummary) ||
|
|
635
664
|
(env ? [env.osName || env.platform, env.archLabel].filter(Boolean).join(" · ") : "环境未知");
|
|
636
665
|
const engineText = !state ? "" : engineTextOf(engine);
|
|
666
|
+
/* 平台族:设置面板与空状态文案都要用,必须在下面的组件定义之前算出来 */
|
|
667
|
+
const family = (env && env.family) || (platform === "darwin" ? "macos" : "windows");
|
|
637
668
|
|
|
638
669
|
const filtered = react.useMemo(() => {
|
|
639
670
|
if (!items) return [];
|
|
@@ -909,7 +940,17 @@ window.__ModuleLoader__.load({
|
|
|
909
940
|
"还没有检测过,点右上角『刷新检测』开始"
|
|
910
941
|
);
|
|
911
942
|
}
|
|
912
|
-
if (!items.length)
|
|
943
|
+
if (!items.length) {
|
|
944
|
+
// 分平台给出可执行的下一步,而不是一句「未检测到任何已安装应用」
|
|
945
|
+
return h(
|
|
946
|
+
"div",
|
|
947
|
+
{ className: "appupd-empty" },
|
|
948
|
+
family === "macos"
|
|
949
|
+
? "未在应用目录里找到应用。若确认本机有应用,请看看下面的运行日志里「枚举应用目录」的记录;" +
|
|
950
|
+
"应用装在非标准位置时本插件看不到它。"
|
|
951
|
+
: "未检测到任何已安装应用"
|
|
952
|
+
);
|
|
953
|
+
}
|
|
913
954
|
return h("div", { className: "appupd-empty" }, "没有匹配的应用");
|
|
914
955
|
};
|
|
915
956
|
|
|
@@ -919,7 +960,10 @@ window.__ModuleLoader__.load({
|
|
|
919
960
|
|
|
920
961
|
const settings = (state && state.settings) || {};
|
|
921
962
|
const dryRunOn = Boolean(settings.dryRun);
|
|
922
|
-
const
|
|
963
|
+
const visibleToggles = togglesFor(family);
|
|
964
|
+
const effectiveDir =
|
|
965
|
+
(state && state.downloadDir) ||
|
|
966
|
+
(family === "macos" ? "~/Downloads/DSH App Updater" : "%USERPROFILE%\\Downloads\\DSH App Updater");
|
|
923
967
|
|
|
924
968
|
const dryRunBanner = dryRunOn
|
|
925
969
|
? h(
|
|
@@ -1043,7 +1087,7 @@ window.__ModuleLoader__.load({
|
|
|
1043
1087
|
)
|
|
1044
1088
|
)
|
|
1045
1089
|
),
|
|
1046
|
-
...
|
|
1090
|
+
...visibleToggles.map((t) =>
|
|
1047
1091
|
h(
|
|
1048
1092
|
"div",
|
|
1049
1093
|
{ className: "appupd-field", key: t.key },
|
package/lib/engine.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { readRegistryApps, normalizeName } from "./registry.js";
|
|
28
28
|
import { compareVersions, isNewer, looksLikeVersion } from "./version.js";
|
|
29
29
|
import { detectEnvironment, describeEnvironment, engineFor, ENGINE_BREW, ENGINE_WINGET } from "./platform.js";
|
|
30
|
+
import { scanAppBundles, fetchCaskCatalog, parseCaskCatalog, buildMacosItems } from "./macos.js";
|
|
30
31
|
|
|
31
32
|
const INSTALLER_EXT = /\.(exe|msi|msix|appx|msixbundle|pkg|dmg)$/i;
|
|
32
33
|
|
|
@@ -1113,88 +1114,70 @@ export function parseBrewOutdated(text) {
|
|
|
1113
1114
|
|
|
1114
1115
|
async function scanMacos({ settings, log, signal, env, startedAt, warnings }) {
|
|
1115
1116
|
const brewPath = findBrew();
|
|
1116
|
-
const items = [];
|
|
1117
1117
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1118
|
+
/* ---------------------------------------------------------------- *
|
|
1119
|
+
* 1. 先枚举「本机到底装了哪些应用」——这一步不依赖 Homebrew。
|
|
1120
|
+
* 旧实现只读 `brew list --cask`,没走 Homebrew 装的应用一个都看不到,
|
|
1121
|
+
* 于是正常 Mac 上就是「未检测到任何已安装应用」。
|
|
1122
|
+
* ---------------------------------------------------------------- */
|
|
1123
|
+
log("info", "正在枚举应用目录(/Applications 等)…");
|
|
1124
|
+
const apps = await scanAppBundles({ log });
|
|
1125
|
+
|
|
1126
|
+
/* ---------------------------------------------------------------- *
|
|
1127
|
+
* 2. Homebrew:哪些 cask 已装、哪些有新版(没装 Homebrew 也继续)。
|
|
1128
|
+
* ---------------------------------------------------------------- */
|
|
1129
|
+
let brewVersion = "";
|
|
1130
|
+
const brews = new Map();
|
|
1131
|
+
const outdated = new Map();
|
|
1132
|
+
|
|
1133
|
+
if (brewPath) {
|
|
1134
|
+
const verRes = await runBrew(brewPath, ["--version"], { timeoutMs: 30000 });
|
|
1135
|
+
brewVersion = firstLine(outputText(verRes)).replace(/^Homebrew\s+/i, "");
|
|
1136
|
+
log("info", `Homebrew ${brewVersion}(${brewPath})· ${env.archLabel}`);
|
|
1137
|
+
|
|
1138
|
+
const listRes = await runBrew(brewPath, ["list", "--cask", "--versions"], { signal });
|
|
1139
|
+
for (const [token, version] of parseBrewCaskVersions(listRes.stdout)) brews.set(token, version);
|
|
1140
|
+
log("info", `Homebrew 报告 ${brews.size} 个已安装 cask`);
|
|
1141
|
+
|
|
1142
|
+
const outRes = await runBrew(brewPath, ["outdated", "--json=v2", "--cask", "--greedy"], { signal });
|
|
1143
|
+
for (const [token, version] of parseBrewOutdated(outRes.stdout)) outdated.set(token, version);
|
|
1144
|
+
log("info", `其中 ${outdated.size} 个有新版本`);
|
|
1145
|
+
} else {
|
|
1146
|
+
warnings.push("未找到 Homebrew:可以列出已安装应用,但无法判断/驱动更新(安装 Homebrew 后可一键升级 cask 应用)");
|
|
1120
1147
|
log("warn", warnings[warnings.length - 1]);
|
|
1121
|
-
return {
|
|
1122
|
-
env,
|
|
1123
|
-
engine: { id: ENGINE_BREW, available: false, version: "", path: "" },
|
|
1124
|
-
scannedAt: Date.now(),
|
|
1125
|
-
startedAt,
|
|
1126
|
-
stats: { total: 0, upgradable: 0, unsupported: 0 },
|
|
1127
|
-
items,
|
|
1128
|
-
warnings,
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
const verRes = await runBrew(brewPath, ["--version"], { timeoutMs: 30000 });
|
|
1133
|
-
const brewVersion = firstLine(outputText(verRes)).replace(/^Homebrew\s+/i, "");
|
|
1134
|
-
log("info", `Homebrew ${brewVersion}(${brewPath})· ${env.archLabel}`);
|
|
1135
|
-
|
|
1136
|
-
const listRes = await runBrew(brewPath, ["list", "--cask", "--versions"], { signal });
|
|
1137
|
-
const installed = parseBrewCaskVersions(listRes.stdout);
|
|
1138
|
-
log("info", `Homebrew 报告 ${installed.size} 个已安装 cask 应用`);
|
|
1139
|
-
|
|
1140
|
-
const outRes = await runBrew(brewPath, ["outdated", "--json=v2", "--cask", "--greedy"], { signal });
|
|
1141
|
-
const outdated = parseBrewOutdated(outRes.stdout);
|
|
1142
|
-
log("info", `其中 ${outdated.size} 个有新版本`);
|
|
1143
|
-
|
|
1144
|
-
for (const [token, version] of installed) {
|
|
1145
|
-
const available = outdated.get(token) || "";
|
|
1146
|
-
const hasUpdate = Boolean(available) && (!version || isNewer(available, version));
|
|
1147
|
-
items.push({
|
|
1148
|
-
id: `brew:${token}`,
|
|
1149
|
-
name: token,
|
|
1150
|
-
version: version || "",
|
|
1151
|
-
available: hasUpdate ? available : "",
|
|
1152
|
-
publisher: "Homebrew cask",
|
|
1153
|
-
source: "homebrew",
|
|
1154
|
-
installLocation: "",
|
|
1155
|
-
hasUpdate,
|
|
1156
|
-
updateSupported: hasUpdate,
|
|
1157
|
-
reason: hasUpdate ? null : null,
|
|
1158
|
-
kind: "cask",
|
|
1159
|
-
});
|
|
1160
1148
|
}
|
|
1161
1149
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
publisher: "Homebrew cask",
|
|
1171
|
-
source: "homebrew",
|
|
1172
|
-
installLocation: "",
|
|
1173
|
-
hasUpdate: true,
|
|
1174
|
-
updateSupported: true,
|
|
1175
|
-
reason: null,
|
|
1176
|
-
kind: "cask",
|
|
1177
|
-
});
|
|
1150
|
+
/* ---------------------------------------------------------------- *
|
|
1151
|
+
* 3. cask 目录:把 .app 反查到 cask token,才能知道官网最新版本。
|
|
1152
|
+
* 抓不到就降级(只显示 Homebrew 自己报告的信息),不中断扫描。
|
|
1153
|
+
* ---------------------------------------------------------------- */
|
|
1154
|
+
let catalog = null;
|
|
1155
|
+
if (brewPath) {
|
|
1156
|
+
const list = await fetchCaskCatalog({ log, warnings });
|
|
1157
|
+
if (list) catalog = parseCaskCatalog(list);
|
|
1178
1158
|
}
|
|
1179
1159
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
});
|
|
1160
|
+
/* ---------------------------------------------------------------- *
|
|
1161
|
+
* 4. 合并:已装应用 + Homebrew 管理状态 → 最终列表
|
|
1162
|
+
* ---------------------------------------------------------------- */
|
|
1163
|
+
const built = buildMacosItems({ apps, brews, outdated, catalog, settings });
|
|
1164
|
+
const items = built.items;
|
|
1165
|
+
for (const w of built.warnings) if (!warnings.includes(w)) warnings.push(w);
|
|
1184
1166
|
|
|
1185
1167
|
const stats = {
|
|
1186
1168
|
total: items.length,
|
|
1187
1169
|
upgradable: items.filter((i) => i.hasUpdate).length,
|
|
1188
|
-
unsupported:
|
|
1170
|
+
unsupported: items.filter((i) => !i.updateSupported).length,
|
|
1189
1171
|
};
|
|
1190
1172
|
log("success", `扫描完成:${stats.total} 个应用,${stats.upgradable} 个可更新`);
|
|
1173
|
+
|
|
1191
1174
|
if (settings.includeStoreApps) {
|
|
1192
|
-
warnings.push("
|
|
1175
|
+
warnings.push("App Store 应用只列出、不由本插件更新(由 App Store 自己负责升级)");
|
|
1193
1176
|
}
|
|
1194
1177
|
|
|
1195
1178
|
return {
|
|
1196
1179
|
env,
|
|
1197
|
-
engine: { id: ENGINE_BREW, available:
|
|
1180
|
+
engine: { id: ENGINE_BREW, available: Boolean(brewPath), version: brewVersion, path: brewPath || "" },
|
|
1198
1181
|
scannedAt: Date.now(),
|
|
1199
1182
|
startedAt,
|
|
1200
1183
|
stats,
|
|
@@ -1227,6 +1210,19 @@ async function runMacosUpdates({ ids, settings, log, onProgress, signal, env })
|
|
|
1227
1210
|
log("warn", `已取消:${rawId}`);
|
|
1228
1211
|
break;
|
|
1229
1212
|
}
|
|
1213
|
+
// 非 Homebrew 管理的应用(macapp:…)没有可交给 brew 的 token:
|
|
1214
|
+
// 明确报「不由本插件更新」,而不是把一个 bundle id 当 cask 名去执行。
|
|
1215
|
+
if (!String(rawId).startsWith("brew:")) {
|
|
1216
|
+
index += 1;
|
|
1217
|
+
results.push({
|
|
1218
|
+
id: rawId,
|
|
1219
|
+
ok: false,
|
|
1220
|
+
error: "该应用不由 Homebrew 管理(拖 DMG / 手动安装),本插件只能列出、不能更新它;请在应用内或官网升级",
|
|
1221
|
+
});
|
|
1222
|
+
log("warn", `${rawId} 跳过:非 Homebrew 管理的应用`);
|
|
1223
|
+
onProgress({ running: true, total: queue.length, done: index, current: null, results });
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1230
1226
|
index += 1;
|
|
1231
1227
|
const token = String(rawId).replace(/^brew:/, "");
|
|
1232
1228
|
onProgress({ running: true, total: queue.length, done: index - 1, current: token });
|
package/lib/macos.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS 应用清单:把「本机装了哪些应用」真正列出来。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个模块:1.3.x 的 macOS 扫描只读了 `brew list --cask`,也就是
|
|
5
|
+
* **只有通过 Homebrew 装过的应用才会出现在列表里**。一台正常使用的 Mac 上,
|
|
6
|
+
* 绝大多数应用是拖 DMG 装的,于是 `brew list --cask` 返回空 → 界面显示
|
|
7
|
+
* 「未检测到任何已安装应用」。这不是「没有应用」,而是**没有枚举应用**。
|
|
8
|
+
*
|
|
9
|
+
* 现在的做法:
|
|
10
|
+
* 1. 枚举标准应用目录里的 `.app`(/Applications、/System/Applications 及其
|
|
11
|
+
* Utilities 子目录、~/Applications),从每个 bundle 的 Info.plist 读版本;
|
|
12
|
+
* 2. 用 Homebrew 的 cask 目录(formulae.brew.sh,缓存一天)把 `.app` 反向匹配到
|
|
13
|
+
* cask token,从而知道**官网最新版本**;
|
|
14
|
+
* 3. 只有「确实由 Homebrew 管理」的应用才标记为可一键更新 —— 其余应用如实列出,
|
|
15
|
+
* 但标注为不可由本插件更新,绝不假装能更新。
|
|
16
|
+
*
|
|
17
|
+
* 这里只做「读」,不改动任何东西;所有命令都是绝对路径 + 参数数组,不经 shell。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
|
|
25
|
+
import { runProcess } from "./proc.js";
|
|
26
|
+
import { dataDir } from "./state.js";
|
|
27
|
+
import { isNewer, looksLikeVersion } from "./version.js";
|
|
28
|
+
|
|
29
|
+
/** macOS 放应用的标准目录(约定:这些目录第一层就是 `.app`)。 */
|
|
30
|
+
export function applicationDirs(home = homedir()) {
|
|
31
|
+
// 这些是 macOS 的 POSIX 路径,用字符串拼接而不是 path.join:
|
|
32
|
+
// join 在 Windows 上会产出反斜杠,跨平台跑单测时就对不上了。
|
|
33
|
+
const h = String(home || "").replace(/\/+$/, "");
|
|
34
|
+
return [
|
|
35
|
+
"/Applications",
|
|
36
|
+
"/Applications/Utilities",
|
|
37
|
+
"/System/Applications",
|
|
38
|
+
"/System/Applications/Utilities",
|
|
39
|
+
`${h}/Applications`,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** cask 目录的缓存文件(放插件自己的数据目录,不碰宿主配置)。 */
|
|
44
|
+
export function catalogCachePath(dir) {
|
|
45
|
+
return join(dir || dataDir(), "cask-catalog.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CATALOG_URL = "https://formulae.brew.sh/api/cask.json";
|
|
49
|
+
const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 从 cask 目录 JSON 建索引:
|
|
53
|
+
* - byApp:`.app` 文件名(小写)→ 条目**数组**,用来「从已装应用反查 cask」;
|
|
54
|
+
* - byToken:cask token → 条目,用来「从已装 cask 正查最新版本」。
|
|
55
|
+
*
|
|
56
|
+
* 一个 .app 可能被多个 cask 声明(例如 `iterm2` 与 `iterm2@nightly` 都装
|
|
57
|
+
* `iTerm.app`),只留最后一个会让匹配随目录顺序漂移 —— 所以这里保留全部候选,
|
|
58
|
+
* 由 pickCask 按「已装 > 非 nightly/非弃用 > token 精确匹配」挑选。
|
|
59
|
+
*
|
|
60
|
+
* @param {any} list formulae.brew.sh/api/cask.json 的内容
|
|
61
|
+
*/
|
|
62
|
+
export function parseCaskCatalog(list) {
|
|
63
|
+
const byApp = new Map();
|
|
64
|
+
const byToken = new Map();
|
|
65
|
+
const addCandidate = (appName, entry) => {
|
|
66
|
+
const key = String(appName).toLowerCase();
|
|
67
|
+
const bucket = byApp.get(key);
|
|
68
|
+
if (bucket) bucket.push(entry);
|
|
69
|
+
else byApp.set(key, [entry]);
|
|
70
|
+
};
|
|
71
|
+
for (const c of Array.isArray(list) ? list : []) {
|
|
72
|
+
if (!c || typeof c !== "object" || !c.token) continue;
|
|
73
|
+
const appNames = [];
|
|
74
|
+
for (const art of Array.isArray(c.artifacts) ? c.artifacts : []) {
|
|
75
|
+
if (!art || typeof art !== "object") continue;
|
|
76
|
+
for (const raw of Array.isArray(art.app) ? art.app : []) {
|
|
77
|
+
// 形如 "$APPDIR/Foo.app" 或 "Foo.app",只取最后一段
|
|
78
|
+
const base = String(raw).split("/").pop();
|
|
79
|
+
if (base && /\.app$/i.test(base)) appNames.push(base);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const entry = {
|
|
83
|
+
token: String(c.token),
|
|
84
|
+
version: String(c.version == null ? "" : c.version).trim(),
|
|
85
|
+
name: Array.isArray(c.name) && c.name.length ? String(c.name[0]) : String(c.token),
|
|
86
|
+
appNames,
|
|
87
|
+
deprecated: Boolean(c.deprecated),
|
|
88
|
+
disabled: Boolean(c.disabled),
|
|
89
|
+
autoUpdates: Boolean(c.auto_updates),
|
|
90
|
+
};
|
|
91
|
+
byToken.set(entry.token, entry);
|
|
92
|
+
for (const appName of appNames) addCandidate(appName, entry);
|
|
93
|
+
}
|
|
94
|
+
return { byApp, byToken };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** token 里的变体后缀(iterm2@nightly → iterm2),用于排序时优先主 cask。 */
|
|
98
|
+
function baseToken(token) {
|
|
99
|
+
return String(token || "").split("@")[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 在多个候选 cask 里挑出最可能是「这个 .app 对应的那个 cask」。
|
|
104
|
+
*
|
|
105
|
+
* 优先级(越靠前越优先):
|
|
106
|
+
* 1. 已安装(token 出现在 brews 里)—— 已装的就是用户实际在用的那个;
|
|
107
|
+
* 2. 非 deprecated / disabled;
|
|
108
|
+
* 3. 主 token(不带 @nightly 这类变体后缀);
|
|
109
|
+
* 4. token 与应用名/应用文件名归一化后相等。
|
|
110
|
+
*/
|
|
111
|
+
export function pickCask(candidates, { appName, bundleName, brews = new Map() } = {}) {
|
|
112
|
+
if (!Array.isArray(candidates) || !candidates.length) return null;
|
|
113
|
+
const keys = new Set([normalizeAppKey(appName), normalizeAppKey(bundleName)].filter(Boolean));
|
|
114
|
+
const score = (entry) => {
|
|
115
|
+
let s = 0;
|
|
116
|
+
if (brews.has(entry.token)) s += 100;
|
|
117
|
+
if (!entry.deprecated && !entry.disabled) s += 10;
|
|
118
|
+
if (entry.token === baseToken(entry.token)) s += 5;
|
|
119
|
+
if (keys.has(normalizeAppKey(entry.token))) s += 3;
|
|
120
|
+
for (const n of entry.appNames) if (keys.has(normalizeAppKey(n))) s += 1;
|
|
121
|
+
return s;
|
|
122
|
+
};
|
|
123
|
+
let best = candidates[0];
|
|
124
|
+
let bestScore = score(best);
|
|
125
|
+
for (const candidate of candidates.slice(1)) {
|
|
126
|
+
const s = score(candidate);
|
|
127
|
+
if (s > bestScore) {
|
|
128
|
+
best = candidate;
|
|
129
|
+
bestScore = s;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return best;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 名字归一化:去掉 .app、空格与标点,只留字母数字,用于 token ↔ 应用名兜底匹配。 */
|
|
136
|
+
export function normalizeAppKey(name) {
|
|
137
|
+
return String(name || "")
|
|
138
|
+
.replace(/\.app$/i, "")
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 在标准应用目录里找 `.app`(不进入 bundle 内部,避免把框架当应用)。
|
|
145
|
+
* @returns {Promise<string[]>} 绝对路径,已去重
|
|
146
|
+
*/
|
|
147
|
+
export async function listAppBundles(dirs, depth = 1) {
|
|
148
|
+
const found = [];
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const walk = async (dir, level) => {
|
|
151
|
+
let entries;
|
|
152
|
+
try {
|
|
153
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
154
|
+
} catch {
|
|
155
|
+
return; // 目录不存在 / 无权限:跳过,不影响其它目录
|
|
156
|
+
}
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const full = join(dir, entry.name);
|
|
159
|
+
if (/\.app$/i.test(entry.name)) {
|
|
160
|
+
if (!seen.has(full)) {
|
|
161
|
+
seen.add(full);
|
|
162
|
+
found.push(full);
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (level > 0 && (entry.isDirectory() || entry.isSymbolicLink())) {
|
|
167
|
+
await walk(full, level - 1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
for (const dir of dirs) await walk(dir, depth);
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 用 plutil 读 Info.plist(macOS 自带,binary plist 也能转 JSON)。 */
|
|
176
|
+
async function readInfoPlist(appPath) {
|
|
177
|
+
const plist = join(appPath, "Contents", "Info.plist");
|
|
178
|
+
const res = await runProcess("/usr/bin/plutil", ["-convert", "json", "-o", "-", plist], { timeoutMs: 15000 });
|
|
179
|
+
if (!res.ok) return null;
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(res.stdout);
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 读一个 `.app` 的元信息。
|
|
189
|
+
* @returns {Promise<{path:string,bundleName:string,name:string,version:string,bundleId:string,appStore:boolean,system:boolean}>}
|
|
190
|
+
*/
|
|
191
|
+
export async function readAppBundle(appPath) {
|
|
192
|
+
// macOS 上路径一定用 /,但单测在 Windows 上跑,两种分隔符都要能取到最后一段
|
|
193
|
+
const bundleName = String(appPath).split(/[\\/]/).filter(Boolean).pop() || String(appPath);
|
|
194
|
+
const info = {
|
|
195
|
+
path: appPath,
|
|
196
|
+
bundleName,
|
|
197
|
+
name: bundleName.replace(/\.app$/i, ""),
|
|
198
|
+
version: "",
|
|
199
|
+
bundleId: "",
|
|
200
|
+
appStore: existsSync(join(appPath, "Contents", "_MASReceipt", "receipt")),
|
|
201
|
+
system: appPath.startsWith("/System/"),
|
|
202
|
+
};
|
|
203
|
+
const plist = await readInfoPlist(appPath);
|
|
204
|
+
if (plist) {
|
|
205
|
+
const short = plist.CFBundleShortVersionString;
|
|
206
|
+
const bundle = plist.CFBundleVersion;
|
|
207
|
+
info.version = String(short || bundle || "").trim();
|
|
208
|
+
info.bundleId = String(plist.CFBundleIdentifier || "").trim();
|
|
209
|
+
const display = plist.CFBundleDisplayName || plist.CFBundleName;
|
|
210
|
+
if (display) info.name = String(display).trim();
|
|
211
|
+
}
|
|
212
|
+
if (!info.version) {
|
|
213
|
+
// plutil 失败时的兜底:defaults 直接读单个键
|
|
214
|
+
const res = await runProcess(
|
|
215
|
+
"/usr/bin/defaults",
|
|
216
|
+
["read", join(appPath, "Contents", "Info"), "CFBundleShortVersionString"],
|
|
217
|
+
{ timeoutMs: 15000 }
|
|
218
|
+
);
|
|
219
|
+
if (res.ok) info.version = res.stdout.trim();
|
|
220
|
+
}
|
|
221
|
+
return info;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 枚举并读取全部已安装应用(并发有限,避免一次开几百个进程)。 */
|
|
225
|
+
export async function scanAppBundles({ dirs, log, concurrency = 8 } = {}) {
|
|
226
|
+
const paths = await listAppBundles(dirs || applicationDirs());
|
|
227
|
+
const out = [];
|
|
228
|
+
let index = 0;
|
|
229
|
+
const worker = async () => {
|
|
230
|
+
while (index < paths.length) {
|
|
231
|
+
const current = paths[index];
|
|
232
|
+
index += 1;
|
|
233
|
+
try {
|
|
234
|
+
out.push(await readAppBundle(current));
|
|
235
|
+
} catch {
|
|
236
|
+
/* 单个 bundle 读不出来不影响其它应用 */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, paths.length || 1) }, worker));
|
|
241
|
+
if (log) log("info", `在应用目录里找到 ${out.length} 个 .app`);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 读缓存的 cask 目录(超过 TTL 视为没有缓存)。 */
|
|
246
|
+
export async function readCatalogCache(file, now = Date.now()) {
|
|
247
|
+
try {
|
|
248
|
+
const raw = JSON.parse(await readFile(file, "utf8"));
|
|
249
|
+
if (!raw || typeof raw.fetchedAt !== "number") return null;
|
|
250
|
+
if (now - raw.fetchedAt > CATALOG_TTL_MS) return null;
|
|
251
|
+
return raw.list || null;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 抓取 cask 目录并写缓存;失败返回 null(调用方降级,不中断扫描)。 */
|
|
258
|
+
export async function fetchCaskCatalog({ file, log, warnings, fetchImpl } = {}) {
|
|
259
|
+
const cacheFile = file || catalogCachePath();
|
|
260
|
+
const cached = await readCatalogCache(cacheFile);
|
|
261
|
+
if (cached) return cached;
|
|
262
|
+
|
|
263
|
+
const run = fetchImpl || (async () => {
|
|
264
|
+
const res = await runProcess("/usr/bin/curl", ["-fsSL", "--max-time", "120", CATALOG_URL], { timeoutMs: 180000 });
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
throw new Error(String(res.stderr || "").trim().split("\n")[0] || `curl 退出码 ${res.code}`);
|
|
267
|
+
}
|
|
268
|
+
return JSON.parse(res.stdout);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const list = await run();
|
|
273
|
+
if (!Array.isArray(list)) throw new Error("cask 目录格式不符合预期");
|
|
274
|
+
try {
|
|
275
|
+
await mkdir(dataDir(), { recursive: true });
|
|
276
|
+
await writeFile(cacheFile, JSON.stringify({ fetchedAt: Date.now(), list }), "utf8");
|
|
277
|
+
} catch {
|
|
278
|
+
/* 缓存写不进去不影响本次扫描 */
|
|
279
|
+
}
|
|
280
|
+
if (log) log("info", `已读取 Homebrew cask 目录(${list.length} 个 cask)`);
|
|
281
|
+
return list;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const message = String((err && err.message) || err);
|
|
284
|
+
if (warnings) warnings.push(`读取 Homebrew cask 目录失败(不影响列出应用,但无法判断是否有新版本):${message}`);
|
|
285
|
+
if (log) log("warn", `读取 Homebrew cask 目录失败:${message}`);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 组装 macOS 应用列表(纯函数,便于单测)。
|
|
292
|
+
*
|
|
293
|
+
* @param {object} input
|
|
294
|
+
* @param {Array} input.apps 已安装的 .app(scanAppBundles 的结果)
|
|
295
|
+
* @param {Map<string,string>} input.brews 已装 cask:token → 已装版本
|
|
296
|
+
* @param {Map<string,string>} input.outdated 可升级 cask:token → 可用版本
|
|
297
|
+
* @param {{byApp:Map,byToken:Map}|null} input.catalog cask 目录索引
|
|
298
|
+
* @param {object} input.settings
|
|
299
|
+
* @returns {{items:Array, warnings:string[]}}
|
|
300
|
+
*/
|
|
301
|
+
export function buildMacosItems({ apps = [], brews = new Map(), outdated = new Map(), catalog = null, settings = {} }) {
|
|
302
|
+
const warnings = [];
|
|
303
|
+
const items = [];
|
|
304
|
+
const byApp = catalog ? catalog.byApp : new Map();
|
|
305
|
+
const byToken = catalog ? catalog.byToken : new Map();
|
|
306
|
+
/** 已被某个 .app 认领的 cask token,避免重复列出 */
|
|
307
|
+
const claimed = new Set();
|
|
308
|
+
|
|
309
|
+
// 没有 cask 目录时也能把「已装 cask」和 .app 对上:用 token 名归一化匹配,
|
|
310
|
+
// 否则同一个应用会既作为 .app 出现、又作为 brew 条目再出现一次。
|
|
311
|
+
const brewByName = new Map();
|
|
312
|
+
for (const token of brews.keys()) brewByName.set(normalizeAppKey(token), token);
|
|
313
|
+
|
|
314
|
+
const matchCask = (app) => {
|
|
315
|
+
const direct = byApp.get(String(app.bundleName || "").toLowerCase());
|
|
316
|
+
if (direct && direct.length) {
|
|
317
|
+
return pickCask(direct, { appName: app.name, bundleName: app.bundleName, brews });
|
|
318
|
+
}
|
|
319
|
+
const key = normalizeAppKey(app.name || app.bundleName);
|
|
320
|
+
for (const entry of byToken.values()) {
|
|
321
|
+
if (normalizeAppKey(entry.token) === key) return entry;
|
|
322
|
+
for (const appName of entry.appNames) {
|
|
323
|
+
if (normalizeAppKey(appName) === key) return entry;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const token = brewByName.get(key);
|
|
327
|
+
if (token) return { token, version: "", name: token, appNames: [] };
|
|
328
|
+
return null;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
for (const app of apps) {
|
|
332
|
+
// 注意:只有**显式**设为 false 才过滤。settings 缺字段时按默认「包含」处理,
|
|
333
|
+
// 否则单测/老状态文件里缺字段会把应用整批吞掉。
|
|
334
|
+
if (settings.includeStoreApps === false && app.appStore) continue;
|
|
335
|
+
const cask = matchCask(app);
|
|
336
|
+
const managed = Boolean(cask && brews.has(cask.token));
|
|
337
|
+
// 非 Homebrew 管理的应用:默认列出(includeUnmanagedApps),关掉后只看 Homebrew 应用
|
|
338
|
+
if (!managed && settings.includeUnmanagedApps === false) continue;
|
|
339
|
+
if (cask && managed) claimed.add(cask.token);
|
|
340
|
+
|
|
341
|
+
const latest = cask && looksLikeVersion(cask.version) ? cask.version : "";
|
|
342
|
+
const forced = managed && outdated.get(cask.token) ? String(outdated.get(cask.token)) : "";
|
|
343
|
+
|
|
344
|
+
let available = "";
|
|
345
|
+
if (managed) {
|
|
346
|
+
if (forced) available = forced;
|
|
347
|
+
else if (latest && looksLikeVersion(app.version) && isNewer(latest, app.version)) available = latest;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let reason = null;
|
|
351
|
+
if (app.system && !managed) reason = "system-component";
|
|
352
|
+
else if (app.appStore && !managed) reason = "store-managed";
|
|
353
|
+
else if (!managed) reason = "not-managed";
|
|
354
|
+
|
|
355
|
+
const hasUpdate = Boolean(managed && available);
|
|
356
|
+
items.push({
|
|
357
|
+
id: managed ? `brew:${cask.token}` : `macapp:${app.bundleId || app.bundleName}`,
|
|
358
|
+
name: app.name || app.bundleName,
|
|
359
|
+
version: app.version || "",
|
|
360
|
+
available,
|
|
361
|
+
publisher: cask ? `Homebrew cask · ${cask.token}` : app.bundleId || "",
|
|
362
|
+
source: managed ? "homebrew" : app.appStore ? "appstore" : "app",
|
|
363
|
+
installLocation: app.path,
|
|
364
|
+
hasUpdate,
|
|
365
|
+
updateSupported: hasUpdate,
|
|
366
|
+
reason: hasUpdate ? null : reason,
|
|
367
|
+
kind: managed ? "cask" : "app",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 通过 Homebrew 装的、但标准目录里没找到 .app 的(字体、命令行 cask 等)也要列出来。
|
|
372
|
+
// 它们本来就由 Homebrew 管理,不受「包含非 Homebrew 应用」开关影响。
|
|
373
|
+
for (const [token, version] of brews) {
|
|
374
|
+
if (claimed.has(token)) continue;
|
|
375
|
+
const entry = byToken.get(token);
|
|
376
|
+
const forced = outdated.get(token) ? String(outdated.get(token)) : "";
|
|
377
|
+
const latest = entry && looksLikeVersion(entry.version) ? entry.version : "";
|
|
378
|
+
let available = "";
|
|
379
|
+
if (forced) available = forced;
|
|
380
|
+
else if (latest && looksLikeVersion(version) && isNewer(latest, version)) available = latest;
|
|
381
|
+
const hasUpdate = Boolean(available);
|
|
382
|
+
items.push({
|
|
383
|
+
id: `brew:${token}`,
|
|
384
|
+
name: entry ? entry.name : token,
|
|
385
|
+
version: version || "",
|
|
386
|
+
available,
|
|
387
|
+
publisher: `Homebrew cask · ${token}`,
|
|
388
|
+
source: "homebrew",
|
|
389
|
+
installLocation: "",
|
|
390
|
+
hasUpdate,
|
|
391
|
+
updateSupported: hasUpdate,
|
|
392
|
+
reason: hasUpdate ? null : null,
|
|
393
|
+
kind: "cask",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!catalog) {
|
|
398
|
+
warnings.push("没有可用的 Homebrew cask 目录:只显示 Homebrew 报告的更新,非 Homebrew 应用无法判断是否有新版本");
|
|
399
|
+
}
|
|
400
|
+
if (settings.includeUnmanagedApps === false) {
|
|
401
|
+
warnings.push("「包含非 Homebrew 应用」已关闭:只显示由 Homebrew 管理的应用");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
items.sort((a, b) => {
|
|
405
|
+
if (a.hasUpdate !== b.hasUpdate) return a.hasUpdate ? -1 : 1;
|
|
406
|
+
return String(a.name).localeCompare(String(b.name));
|
|
407
|
+
});
|
|
408
|
+
return { items, warnings };
|
|
409
|
+
}
|
package/lib/platform.js
CHANGED
|
@@ -178,12 +178,14 @@ export function engineFor(family) {
|
|
|
178
178
|
/** 该 环境+引擎 组合下能做什么。 */
|
|
179
179
|
export function capabilitiesFor(env, engineAvailable) {
|
|
180
180
|
const family = env ? env.family : "other";
|
|
181
|
-
|
|
181
|
+
// macOS 即使没有 Homebrew 也能枚举 .app(列出已装应用),只是无法判断/驱动更新
|
|
182
|
+
const scan = family === "windows" || family === "macos";
|
|
183
|
+
const canUpdate = family === "windows" ? Boolean(engineAvailable) : Boolean(engineAvailable);
|
|
182
184
|
return {
|
|
183
185
|
scan: Boolean(scan),
|
|
184
|
-
update: Boolean(
|
|
185
|
-
checkVersions: Boolean(
|
|
186
|
-
storeAppsIncluded: family === "windows",
|
|
186
|
+
update: Boolean(canUpdate),
|
|
187
|
+
checkVersions: Boolean(canUpdate),
|
|
188
|
+
storeAppsIncluded: family === "windows" || family === "macos",
|
|
187
189
|
note:
|
|
188
190
|
family === "windows"
|
|
189
191
|
? engineAvailable
|
|
@@ -192,7 +194,7 @@ export function capabilitiesFor(env, engineAvailable) {
|
|
|
192
194
|
: family === "macos"
|
|
193
195
|
? engineAvailable
|
|
194
196
|
? ""
|
|
195
|
-
: "未找到 Homebrew
|
|
197
|
+
: "未找到 Homebrew:可以列出已安装应用,但无法判断/驱动更新;安装 Homebrew 后可一键升级 cask 应用"
|
|
196
198
|
: "当前系统没有可用的更新引擎(本插件支持 Windows 与 macOS)",
|
|
197
199
|
};
|
|
198
200
|
}
|
package/lib/state.js
CHANGED
|
@@ -25,6 +25,8 @@ export function defaultSettings() {
|
|
|
25
25
|
return {
|
|
26
26
|
includeStoreApps: true,
|
|
27
27
|
includeRegistryOnly: true,
|
|
28
|
+
// macOS:把非 Homebrew 管理(拖 DMG / 手动装)的应用也列出来(只列出,不更新)
|
|
29
|
+
includeUnmanagedApps: true,
|
|
28
30
|
downloadDir: "",
|
|
29
31
|
keepInstallers: true,
|
|
30
32
|
useWingetDownload: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-app-updater",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App Updater for DeepSeek Harness: detect every installed app that has a newer version and update it with one click by downloading the vendor's full installer from its official website and installing it silently — without launching the app. Windows (winget) and macOS (Homebrew), architecture-aware (ARM64 / x64). · 检测本机所有已安装应用是否有新版本,并从各自官网下载全量安装包一键静默更新(不需要打开被检测的软件);自动识别 Windows / macOS 与 ARM64 / x64 架构,按架构下载对应安装包。",
|
|
6
6
|
"license": "MIT",
|
package/test/render.mjs
CHANGED
|
@@ -359,7 +359,61 @@ tree = rt.render();
|
|
|
359
359
|
group("设置面板与列宽手柄");
|
|
360
360
|
ok(byClass(tree, "appupd-panel").length === 1, "渲染出插件设置面板");
|
|
361
361
|
const switches = byClass(tree, "appupd-switch");
|
|
362
|
-
ok(switches.length === 7, "渲染出 7
|
|
362
|
+
ok(switches.length === 7, "渲染出 7 个设置开关(Windows:不含 macOS 专属项)", switches.length);
|
|
363
|
+
|
|
364
|
+
group("设置面板按平台显示开关");
|
|
365
|
+
{
|
|
366
|
+
const macState = baseState({
|
|
367
|
+
platform: "darwin",
|
|
368
|
+
env: { family: "macos", platform: "darwin", osName: "macOS 15(Sequoia)", archLabel: "Apple Silicon(ARM64)" },
|
|
369
|
+
envSummary: "macOS 15(Sequoia) · Apple Silicon(ARM64)",
|
|
370
|
+
engine: { id: "homebrew", available: true, version: "4.5.0" },
|
|
371
|
+
});
|
|
372
|
+
statePayload = macState;
|
|
373
|
+
rt.reset();
|
|
374
|
+
await tick();
|
|
375
|
+
tree = rt.render();
|
|
376
|
+
await tick();
|
|
377
|
+
tree = rt.render();
|
|
378
|
+
const macSwitches = byClass(tree, "appupd-switch");
|
|
379
|
+
ok(macSwitches.length === 5, "macOS 显示 5 个开关(跨平台 3 + macOS 专属 2)", macSwitches.length);
|
|
380
|
+
const labels = byClass(tree, "appupd-field-label").map(textOf);
|
|
381
|
+
ok(!labels.includes("自动提权安装"), "macOS 不显示「自动提权安装」", labels);
|
|
382
|
+
ok(!labels.includes("下载失败时回退 winget"), "macOS 不显示「下载失败时回退 winget」", labels);
|
|
383
|
+
ok(labels.includes("包含非 Homebrew 应用"), "macOS 显示「包含非 Homebrew 应用」", labels);
|
|
384
|
+
ok(labels.includes("包含 App Store 应用"), "macOS 显示「包含 App Store 应用」", labels);
|
|
385
|
+
ok(labels.includes("演练模式") && labels.includes("保留安装包"), "跨平台开关仍然显示", labels);
|
|
386
|
+
}
|
|
387
|
+
statePayload = baseState();
|
|
388
|
+
rt.reset();
|
|
389
|
+
await tick();
|
|
390
|
+
tree = rt.render();
|
|
391
|
+
await tick();
|
|
392
|
+
tree = rt.render();
|
|
393
|
+
|
|
394
|
+
group("macOS 空状态文案");
|
|
395
|
+
{
|
|
396
|
+
const emptyMac = baseState({
|
|
397
|
+
platform: "darwin",
|
|
398
|
+
env: { family: "macos", platform: "darwin" },
|
|
399
|
+
apps: { scannedAt: Date.now(), stats: { total: 0, upgradable: 0, unsupported: 0 }, items: [] },
|
|
400
|
+
});
|
|
401
|
+
statePayload = emptyMac;
|
|
402
|
+
rt.reset();
|
|
403
|
+
await tick();
|
|
404
|
+
tree = rt.render();
|
|
405
|
+
await tick();
|
|
406
|
+
tree = rt.render();
|
|
407
|
+
const emptyText = textOf(byClass(tree, "appupd-empty")[0] || tree);
|
|
408
|
+
ok(emptyText.includes("应用目录") || emptyText.includes("运行日志"), "macOS 空列表给出可执行的下一步,而不是只说「未检测到」", emptyText.slice(0, 120));
|
|
409
|
+
ok(!emptyText.includes("未检测到任何已安装应用"), "macOS 不再显示会产生误解的那句话");
|
|
410
|
+
}
|
|
411
|
+
statePayload = baseState();
|
|
412
|
+
rt.reset();
|
|
413
|
+
await tick();
|
|
414
|
+
tree = rt.render();
|
|
415
|
+
await tick();
|
|
416
|
+
tree = rt.render();
|
|
363
417
|
ok(byClass(tree, "appupd-grip").length === 4, "渲染出 4 个列宽拖拽手柄", byClass(tree, "appupd-grip").length);
|
|
364
418
|
const ths = walk(tree).filter((n) => n.type === "th");
|
|
365
419
|
ok(ths.length === 6, "表头 6 列", ths.length);
|
package/test/unit.mjs
CHANGED
|
@@ -20,6 +20,13 @@ import {
|
|
|
20
20
|
import { compareVersions, isNewer, looksLikeVersion, splitVersion } from "../lib/version.js";
|
|
21
21
|
import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew, isArchNotFoundError, explainInstallerExitCode, evaluateUpdateResult, findEdgeUpdate, updateHintFor } from "../lib/engine.js";
|
|
22
22
|
import { psQuote, ELEVATION_CANCELLED } from "../lib/elevate.js";
|
|
23
|
+
import {
|
|
24
|
+
applicationDirs,
|
|
25
|
+
parseCaskCatalog,
|
|
26
|
+
pickCask,
|
|
27
|
+
normalizeAppKey,
|
|
28
|
+
buildMacosItems,
|
|
29
|
+
} from "../lib/macos.js";
|
|
23
30
|
import { admissionGate, hasConnectionCredentials, resolveLegacyLoopbackOptIn, LEGACY_LOOPBACK_ENV } from "../lib/index.js";
|
|
24
31
|
import {
|
|
25
32
|
detectEnvironment,
|
|
@@ -232,8 +239,11 @@ ok(envMac.family === "macos" && envMac.osName.includes("Sequoia") && envMac.arch
|
|
|
232
239
|
|
|
233
240
|
ok(capabilitiesFor(envWinArm, false).scan === true, "有 winget 时 Windows 可扫描");
|
|
234
241
|
ok(capabilitiesFor(envWinArm, false).note.includes("winget"), "缺 winget 时给出提示", capabilitiesFor(envWinArm, false).note);
|
|
235
|
-
ok(capabilitiesFor(envMac, false).
|
|
242
|
+
ok(capabilitiesFor(envMac, false).update === false, "macOS 缺 Homebrew 时不可更新");
|
|
243
|
+
ok(capabilitiesFor(envMac, false).note.includes("Homebrew"), "macOS 缺 Homebrew 时提示装 Homebrew", capabilitiesFor(envMac, false).note);
|
|
236
244
|
ok(capabilitiesFor(envMac, true).scan === true, "macOS 有 Homebrew 时可扫描");
|
|
245
|
+
ok(capabilitiesFor(envMac, false).scan === true, "macOS 没有 Homebrew 也能扫描(只是不能更新)——「0 个应用」的真正原因不是没 Homebrew");
|
|
246
|
+
ok(capabilitiesFor(envMac, false).update === false, "macOS 没有 Homebrew 时不可更新");
|
|
237
247
|
ok(capabilitiesFor({ family: "other" }, false).scan === false, "其它系统不可扫描");
|
|
238
248
|
|
|
239
249
|
/* ---------------- Homebrew 解析 ---------------- */
|
|
@@ -246,6 +256,124 @@ ok(outdated.get("visual-studio-code") === "1.95.0" && outdated.get("iterm2") ===
|
|
|
246
256
|
ok(parseBrewOutdated("not json").size === 0, "坏 JSON 不抛错");
|
|
247
257
|
ok(findBrew({ HOMEBREW_PREFIX: "/nonexistent-prefix-for-test" }) === null, "找不到 brew 时返回 null");
|
|
248
258
|
|
|
259
|
+
/* ---------------- macOS 应用枚举(本次修复的核心) ---------------- */
|
|
260
|
+
group("macOS 应用枚举");
|
|
261
|
+
ok(applicationDirs("/Users/x").includes("/Applications"), "枚举 /Applications");
|
|
262
|
+
ok(applicationDirs("/Users/x").includes("/Users/x/Applications"), "枚举 ~/Applications");
|
|
263
|
+
ok(applicationDirs("/Users/x").includes("/System/Applications/Utilities"), "枚举系统工具目录");
|
|
264
|
+
|
|
265
|
+
const catalogList = [
|
|
266
|
+
{
|
|
267
|
+
token: "visual-studio-code",
|
|
268
|
+
version: "1.144.0",
|
|
269
|
+
name: ["Microsoft Visual Studio Code"],
|
|
270
|
+
artifacts: [{ app: ["Visual Studio Code.app"], target: "/Applications/Visual Studio Code.app" }],
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
token: "iterm2",
|
|
274
|
+
version: "3.5.14",
|
|
275
|
+
name: ["iTerm2"],
|
|
276
|
+
artifacts: [{ app: ["iTerm.app"] }],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
token: "raycast",
|
|
280
|
+
version: "1.99.0",
|
|
281
|
+
name: ["Raycast"],
|
|
282
|
+
artifacts: [{ app: ["Raycast.app"] }],
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
const catalog = parseCaskCatalog(catalogList);
|
|
286
|
+
ok(catalog.byToken.get("iterm2").version === "3.5.14", "目录按 token 建索引");
|
|
287
|
+
ok(catalog.byApp.get("iterm.app")[0].token === "iterm2", "目录按 .app 文件名建索引(大小写无关)");
|
|
288
|
+
|
|
289
|
+
const macApps = [
|
|
290
|
+
// 通过 Homebrew 装、且有新版
|
|
291
|
+
{ path: "/Applications/Visual Studio Code.app", bundleName: "Visual Studio Code.app", name: "Visual Studio Code", version: "1.140.0", bundleId: "com.microsoft.VSCode", appStore: false, system: false },
|
|
292
|
+
// 通过 Homebrew 装、已是最新
|
|
293
|
+
{ path: "/Applications/iTerm.app", bundleName: "iTerm.app", name: "iTerm2", version: "3.5.14", bundleId: "com.googlecode.iterm2", appStore: false, system: false },
|
|
294
|
+
// 拖 DMG 装的,Homebrew 不管
|
|
295
|
+
{ path: "/Applications/Raycast.app", bundleName: "Raycast.app", name: "Raycast", version: "1.99.0", bundleId: "com.raycast.macos", appStore: false, system: false },
|
|
296
|
+
// App Store 装的
|
|
297
|
+
{ path: "/Applications/Xcode.app", bundleName: "Xcode.app", name: "Xcode", version: "16.0", bundleId: "com.apple.dt.Xcode", appStore: true, system: false },
|
|
298
|
+
];
|
|
299
|
+
const brews = new Map([["visual-studio-code", "1.140.0"], ["iterm2", "3.5.14"]]);
|
|
300
|
+
const macBuilt = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog, settings: { includeStoreApps: true, includeUnmanagedApps: true } });
|
|
301
|
+
|
|
302
|
+
ok(macBuilt.items.length === 4, "4 个 .app 全部列出(旧实现只会有 2 个)", macBuilt.items.map((i) => i.name));
|
|
303
|
+
const vsc = macBuilt.items.find((i) => i.name === "Visual Studio Code");
|
|
304
|
+
ok(vsc && vsc.hasUpdate && vsc.available === "1.144.0", "Homebrew 管理 + 有新版本 → 可更新并给出目标版本", vsc);
|
|
305
|
+
ok(vsc && vsc.updateSupported === true && vsc.id === "brew:visual-studio-code", "可更新的应用带上 brew: token", vsc && vsc.id);
|
|
306
|
+
const iterm = macBuilt.items.find((i) => i.name === "iTerm2");
|
|
307
|
+
ok(iterm && iterm.hasUpdate === false && iterm.reason === null, "Homebrew 管理且已最新 → 不可更新但没有「不支持」理由", iterm);
|
|
308
|
+
const ray = macBuilt.items.find((i) => i.name === "Raycast");
|
|
309
|
+
ok(ray && ray.hasUpdate === false && ray.reason === "not-managed", "非 Homebrew 应用:列出但标注「非 Homebrew 管理」", ray);
|
|
310
|
+
ok(ray && ray.updateSupported === false, "非 Homebrew 应用不会被标成可更新");
|
|
311
|
+
const xcode = macBuilt.items.find((i) => i.name === "Xcode");
|
|
312
|
+
ok(xcode && xcode.reason === "store-managed" && xcode.source === "appstore", "App Store 应用标注由商店管理", xcode);
|
|
313
|
+
ok(macBuilt.items.filter((i) => i.hasUpdate).length === 1, "只有 1 个可更新");
|
|
314
|
+
|
|
315
|
+
// --greedy 能强制升级的 cask(自更新应用)也要露出可用版本
|
|
316
|
+
const greedy = buildMacosItems({ apps: macApps, brews, outdated: new Map([["iterm2", "3.5.15"]]), catalog, settings: {} });
|
|
317
|
+
ok(greedy.items.find((i) => i.name === "iTerm2").available === "3.5.15", "brew outdated --greedy 报告的版本优先", greedy.items.find((i) => i.name === "iTerm2"));
|
|
318
|
+
|
|
319
|
+
// 关掉「包含非 Homebrew 应用」后只剩 Homebrew 管理的
|
|
320
|
+
const onlyBrew = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog, settings: { includeUnmanagedApps: false } });
|
|
321
|
+
ok(onlyBrew.items.length === 2 && onlyBrew.items.every((i) => i.source === "homebrew"), "关闭开关后只显示 Homebrew 应用", onlyBrew.items.map((i) => i.name));
|
|
322
|
+
|
|
323
|
+
// 关掉「包含 App Store 应用」
|
|
324
|
+
const noStore = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog, settings: { includeStoreApps: false } });
|
|
325
|
+
ok(!noStore.items.some((i) => i.name === "Xcode"), "关闭 App Store 开关后不显示 Xcode");
|
|
326
|
+
|
|
327
|
+
// 没有 cask 目录时降级:仍然列出应用,只是都不认为可更新
|
|
328
|
+
const noCatalog = buildMacosItems({ apps: macApps, brews, outdated: new Map(), catalog: null, settings: {} });
|
|
329
|
+
ok(noCatalog.items.length === 4 && noCatalog.items.every((i) => !i.hasUpdate), "没有 cask 目录也照样列出应用", noCatalog.items.length);
|
|
330
|
+
ok(noCatalog.warnings.some((w) => w.includes("cask 目录")), "没有 cask 目录时给出警告");
|
|
331
|
+
|
|
332
|
+
// Homebrew 装了但 .app 不在标准目录(字体 / 命令行 cask)
|
|
333
|
+
const brewOnly = buildMacosItems({
|
|
334
|
+
apps: [],
|
|
335
|
+
brews: new Map([["font-jetbrains-mono", "2.304"]]),
|
|
336
|
+
outdated: new Map(),
|
|
337
|
+
catalog: parseCaskCatalog([{ token: "font-jetbrains-mono", version: "2.304", name: ["JetBrains Mono"], artifacts: [] }]),
|
|
338
|
+
settings: {},
|
|
339
|
+
});
|
|
340
|
+
ok(brewOnly.items.length === 1 && brewOnly.items[0].id === "brew:font-jetbrains-mono", "没有 .app 的 cask 也能列出", brewOnly.items);
|
|
341
|
+
|
|
342
|
+
/* ---------------- macOS 目录解析 ---------------- */
|
|
343
|
+
group("cask 目录解析");
|
|
344
|
+
const parsedCatalog = parseCaskCatalog([
|
|
345
|
+
{ token: "raycast", version: "1.99.0", name: ["Raycast"], artifacts: [{ app: ["Raycast.app"] }] },
|
|
346
|
+
{ token: "bad-no-artifacts", version: "1.0" },
|
|
347
|
+
null,
|
|
348
|
+
{ version: "9.9" },
|
|
349
|
+
]);
|
|
350
|
+
ok(parsedCatalog.byToken.size === 2, "跳过没有 token 的条目", parsedCatalog.byToken.size);
|
|
351
|
+
ok(!parsedCatalog.byApp.has("bad-no-artifacts"), "artifacts 里没有 app 的不建 byApp 索引");
|
|
352
|
+
ok(normalizeAppKey("Visual Studio Code.app") === "visualstudiocode", "应用名归一化去掉 .app 与空格");
|
|
353
|
+
ok(normalizeAppKey("VS Code") === "vscode", "应用名归一化保留字母数字");
|
|
354
|
+
|
|
355
|
+
group("同名 .app 的多个 cask:挑对那一个");
|
|
356
|
+
// 真实目录里 iterm2 与 iterm2@nightly 都声明 iTerm.app
|
|
357
|
+
const dupCatalog = parseCaskCatalog([
|
|
358
|
+
{ token: "iterm2@nightly", version: "3.6.0", name: ["iTerm2 Nightly"], artifacts: [{ app: ["iTerm.app"] }] },
|
|
359
|
+
{ token: "iterm2", version: "3.7.6", name: ["iTerm2"], artifacts: [{ app: ["iTerm.app"] }] },
|
|
360
|
+
]);
|
|
361
|
+
ok(dupCatalog.byApp.get("iterm.app").length === 2, "两个 cask 都留在候选里", dupCatalog.byApp.get("iterm.app").length);
|
|
362
|
+
ok(
|
|
363
|
+
pickCask(dupCatalog.byApp.get("iterm.app"), { appName: "iTerm2", bundleName: "iTerm.app", brews: new Map() }).token === "iterm2",
|
|
364
|
+
"没有安装记录时优先主 token(不是 @nightly)"
|
|
365
|
+
);
|
|
366
|
+
ok(
|
|
367
|
+
pickCask(dupCatalog.byApp.get("iterm.app"), { appName: "iTerm2", bundleName: "iTerm.app", brews: new Map([["iterm2@nightly", "3.6.0"]]) }).token === "iterm2@nightly",
|
|
368
|
+
"已装的是 nightly 时优先已装的那个"
|
|
369
|
+
);
|
|
370
|
+
// 弃用 / 停用的 cask 排在后面
|
|
371
|
+
const depCatalog = parseCaskCatalog([
|
|
372
|
+
{ token: "foo-old", version: "1.0", deprecated: true, artifacts: [{ app: ["Foo.app"] }] },
|
|
373
|
+
{ token: "foo", version: "2.0", artifacts: [{ app: ["Foo.app"] }] },
|
|
374
|
+
]);
|
|
375
|
+
ok(pickCask(depCatalog.byApp.get("foo.app"), { appName: "Foo", bundleName: "Foo.app", brews: new Map() }).token === "foo", "弃用的 cask 不会被选中");
|
|
376
|
+
|
|
249
377
|
/* ---------------- 提权与安装复核 ---------------- */
|
|
250
378
|
group("提权执行(PowerShell 助手)");
|
|
251
379
|
ok(psQuote("C:\\Program Files\\a.exe") === "'C:\\Program Files\\a.exe'", "psQuote 保留 Windows 路径");
|