dsh-app-updater 1.3.0 → 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 +75 -0
- package/CONTRACT.md +20 -9
- package/README.en.md +37 -12
- package/README.md +36 -12
- package/lib/client.js +68 -4
- package/lib/engine.js +61 -65
- package/lib/index.js +122 -26
- package/lib/macos.js +409 -0
- package/lib/platform.js +7 -5
- package/lib/state.js +2 -0
- package/package.json +2 -2
- package/test/deny.mjs +149 -0
- package/test/harness.mjs +3 -0
- package/test/render.mjs +75 -1
- package/test/unit.mjs +212 -6
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dsh": ">=0.1.5-rc.1 <0.2.0-0 || >=0.2.0-rc.2 <0.3.0-0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"test": "node test/unit.mjs && node test/render.mjs"
|
|
40
|
+
"test": "node test/unit.mjs && node test/render.mjs && node test/deny.mjs"
|
|
41
41
|
},
|
|
42
42
|
"dsh": {
|
|
43
43
|
"bundle": {
|
package/test/deny.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 默认拒绝的端到端自测:宿主没有 connection 服务、且用户没有显式开启兼容模式时,
|
|
3
|
+
* `/dsh-app-updater/*` 的每一条路由都必须 403(1.3.0 的「仅凭回环放行」已移除)。
|
|
4
|
+
* 反方向再验证一次:显式开启兼容模式后,回环请求才会被放行并带上 opt-in 响应头。
|
|
5
|
+
*
|
|
6
|
+
* 运行:node test/deny.mjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
process.env.DSH_HOME = process.env.DSH_HOME || join(here, "..", ".test-home");
|
|
15
|
+
delete process.env.DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK;
|
|
16
|
+
|
|
17
|
+
const plugin = (await import("../lib/index.js")).default;
|
|
18
|
+
|
|
19
|
+
let checks = 0;
|
|
20
|
+
let failures = 0;
|
|
21
|
+
function ok(cond, label, extra) {
|
|
22
|
+
checks += 1;
|
|
23
|
+
if (cond) console.log(` ✓ ${label}`);
|
|
24
|
+
else {
|
|
25
|
+
failures += 1;
|
|
26
|
+
console.log(` ✗ ${label}${extra === undefined ? "" : ` — ${JSON.stringify(extra)}`}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 起一个假宿主(没有 connection 服务),返回服务地址与路由表。 */
|
|
31
|
+
function mount(config) {
|
|
32
|
+
const routes = [];
|
|
33
|
+
const webServer = {
|
|
34
|
+
register(spec) {
|
|
35
|
+
routes.push(spec);
|
|
36
|
+
return () => {};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const ctx = {
|
|
40
|
+
effect(fn) {
|
|
41
|
+
const dispose = fn();
|
|
42
|
+
return () => {
|
|
43
|
+
if (typeof dispose === "function") dispose();
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
inject(deps, fn) {
|
|
47
|
+
// 只提供 webServer,不提供 connection —— 等价于旧宿主
|
|
48
|
+
fn({ webServer });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
plugin.apply(ctx, config);
|
|
52
|
+
|
|
53
|
+
const server = http.createServer((req, res) => {
|
|
54
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
55
|
+
const route = routes.find((r) => r.path === url.pathname);
|
|
56
|
+
if (!route) {
|
|
57
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
58
|
+
res.end(JSON.stringify({ ok: false, error: "no route" }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
Promise.resolve(route.handler(req, res)).catch((err) => {
|
|
62
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
|
63
|
+
res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return { routes, server };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function serve(server) {
|
|
70
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
71
|
+
return `http://127.0.0.1:${server.address().port}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ROUTES = [
|
|
75
|
+
"/dsh-app-updater/state.json",
|
|
76
|
+
"/dsh-app-updater/apps.json",
|
|
77
|
+
"/dsh-app-updater/scan",
|
|
78
|
+
"/dsh-app-updater/update",
|
|
79
|
+
"/dsh-app-updater/cancel",
|
|
80
|
+
"/dsh-app-updater/settings",
|
|
81
|
+
"/dsh-app-updater/open-downloads",
|
|
82
|
+
"/dsh-app-updater/health.json",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/* ------------------------------------------------------------------ *
|
|
86
|
+
* 1. 默认拒绝:没有 connection + 没有兼容模式
|
|
87
|
+
* ------------------------------------------------------------------ */
|
|
88
|
+
console.log("\n默认拒绝(没有 connection 服务、未开启兼容模式)");
|
|
89
|
+
const denied = mount(undefined);
|
|
90
|
+
const deniedBase = await serve(denied.server);
|
|
91
|
+
for (const p of ROUTES) {
|
|
92
|
+
const isWrite = ["/scan", "/update", "/cancel", "/settings", "/open-downloads"].includes(
|
|
93
|
+
p.replace("/dsh-app-updater", "")
|
|
94
|
+
);
|
|
95
|
+
const res = await fetch(deniedBase + p, isWrite
|
|
96
|
+
? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ confirm: true }) }
|
|
97
|
+
: undefined);
|
|
98
|
+
let body = null;
|
|
99
|
+
try {
|
|
100
|
+
body = await res.json();
|
|
101
|
+
} catch {
|
|
102
|
+
body = null;
|
|
103
|
+
}
|
|
104
|
+
ok(
|
|
105
|
+
res.status === 403 && res.headers.get("x-dsh-admission") === "denied" && body && body.ok === false,
|
|
106
|
+
`${p} → 403 + X-DSH-Admission: denied`,
|
|
107
|
+
{ status: res.status, admission: res.headers.get("x-dsh-admission"), body }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
denied.server.close();
|
|
111
|
+
|
|
112
|
+
/* ------------------------------------------------------------------ *
|
|
113
|
+
* 2. 显式开启兼容模式:同一套路由才放行(且只在回环地址上)
|
|
114
|
+
* ------------------------------------------------------------------ */
|
|
115
|
+
console.log("\n显式开启兼容模式(配置 allowLegacyLoopback: true)");
|
|
116
|
+
const legacy = mount({ allowLegacyLoopback: true });
|
|
117
|
+
const legacyBase = await serve(legacy.server);
|
|
118
|
+
|
|
119
|
+
const stateRes = await fetch(legacyBase + "/dsh-app-updater/state.json");
|
|
120
|
+
const state = await stateRes.json();
|
|
121
|
+
ok(
|
|
122
|
+
stateRes.status === 200 && stateRes.headers.get("x-dsh-admission") === "loopback-legacy-optin",
|
|
123
|
+
"state.json → 200 + X-DSH-Admission: loopback-legacy-optin"
|
|
124
|
+
);
|
|
125
|
+
ok(
|
|
126
|
+
state && state.admission && state.admission.mode === "loopback-legacy-optin" && state.admission.allowLegacyLoopback === true,
|
|
127
|
+
"状态接口暴露 admission 现状(供复核)",
|
|
128
|
+
state && state.admission
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const health = await (await fetch(legacyBase + "/dsh-app-updater/health.json")).json();
|
|
132
|
+
ok(
|
|
133
|
+
health && health.admission && health.admission.mode === "loopback-legacy-optin",
|
|
134
|
+
"health.json 同样标注兼容模式",
|
|
135
|
+
health && health.admission
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const badWrite = await (await fetch(legacyBase + "/dsh-app-updater/scan", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify({}),
|
|
142
|
+
})).json();
|
|
143
|
+
ok(badWrite && badWrite.ok === false && String(badWrite.error).includes("confirm"), "兼容模式下写接口仍要求 confirm:true");
|
|
144
|
+
|
|
145
|
+
legacy.server.close();
|
|
146
|
+
|
|
147
|
+
/* ------------------------------------------------------------------ */
|
|
148
|
+
console.log(`\n${failures === 0 ? "全部通过" : "存在失败"}:${checks - failures}/${checks}`);
|
|
149
|
+
process.exit(failures === 0 ? 0 : 1);
|
package/test/harness.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { dirname, join } from "node:path";
|
|
|
11
11
|
|
|
12
12
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
process.env.DSH_HOME = process.env.DSH_HOME || join(here, "..", ".test-home");
|
|
14
|
+
// 这个假 ctx 没有 connection 服务,等价于旧宿主:默认拒绝下路由一律 403。
|
|
15
|
+
// 所以自测里显式开启兼容模式(与用户在旧宿主上手动开启走的是同一段代码)。
|
|
16
|
+
process.env.DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK = "1";
|
|
14
17
|
|
|
15
18
|
const plugin = (await import("../lib/index.js")).default;
|
|
16
19
|
|