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/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
- if (!brewPath) {
1119
- warnings.push("未找到 Homebrew,macOS 上无法检测/更新应用(安装 Homebrew 后重新检测即可)");
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
- // 有更新但没在「已安装」列表里露出的(例如 --greedy 强制升级项)也补进来
1163
- for (const [token, available] of outdated) {
1164
- if (installed.has(token)) continue;
1165
- items.push({
1166
- id: `brew:${token}`,
1167
- name: token,
1168
- version: "",
1169
- available,
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
- items.sort((a, b) => {
1181
- if (a.hasUpdate !== b.hasUpdate) return a.hasUpdate ? -1 : 1;
1182
- return String(a.name).localeCompare(String(b.name));
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: 0,
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("macOS 的应用商店(App Store)应用不在 Homebrew 覆盖范围内,本插件不会更新它们");
1175
+ warnings.push("App Store 应用只列出、不由本插件更新(由 App Store 自己负责升级)");
1193
1176
  }
1194
1177
 
1195
1178
  return {
1196
1179
  env,
1197
- engine: { id: ENGINE_BREW, available: true, version: brewVersion, path: brewPath },
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/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * dsh-app-updater 宿主半身。
3
3
  *
4
4
  * 职责:暴露 HTTP 路由给设置页 UI,串行调度「扫描」与「更新」两条后台任务,
5
- * 把进度写进持久化状态。所有写操作都要求 loopback 来源 + confirm:true。
5
+ * 把进度写进持久化状态。所有路由默认拒绝:先过宿主 connection 准入;写操作另需
6
+ * confirm:true。仅在用户显式开启兼容模式时,才按「回环地址」放行(默认关闭)。
6
7
  *
7
8
  * 设计上不依赖宿主内部服务(不用 llm / tools / agent),只依赖 webServer,
8
9
  * 因此在没有 Web 的 composition 里它会安静地不挂载任何路由。
@@ -86,46 +87,110 @@ export function hasConnectionCredentials(req) {
86
87
  }
87
88
 
88
89
  /**
89
- * connection 准入(宿主信任边界)。
90
+ * 兼容模式开关:只允许在用户**显式开启**时按「回环地址」放行。
91
+ *
92
+ * 1.3.0 在 `connection` 不可用(或没有凭据可核验)时会直接按回环放行,旧宿主上
93
+ * 等于绕过了认证就能调用安装接口 —— 市场复核要求这条路径改为默认拒绝。
94
+ * 因此 1.3.1 起默认关闭,只有下面两种显式动作之一才恢复(并会在响应头、状态接口
95
+ * 与日志里标注,方便复核):
96
+ * - 插件配置 `allowLegacyLoopback: true`(cordis patch 的 `config`)
97
+ * - 环境变量 `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`
98
+ */
99
+ export const LEGACY_LOOPBACK_ENV = "DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK";
100
+
101
+ function truthyFlag(value) {
102
+ if (value === true || value === 1) return true;
103
+ if (typeof value !== "string") return false;
104
+ return /^(1|true|yes|on)$/i.test(value.trim());
105
+ }
106
+
107
+ /** 解析兼容模式开关:插件配置优先,其次环境变量;默认(两者都没有)为 false。 */
108
+ export function resolveLegacyLoopbackOptIn(config, env = process.env) {
109
+ if (config && truthyFlag(config.allowLegacyLoopback)) return true;
110
+ return truthyFlag((env || {})[LEGACY_LOOPBACK_ENV]);
111
+ }
112
+
113
+ /** 兼容模式放行时打标记,便于复核与排障:正常准入不会出现这两个头。 */
114
+ function markLegacyAdmission(res) {
115
+ if (typeof res.setHeader !== "function") return;
116
+ res.setHeader("X-DSH-Admission", "loopback-legacy-optin");
117
+ res.setHeader("X-DSH-Admission-Warning", "legacy-loopback-compatibility-enabled");
118
+ }
119
+
120
+ /** 统一写出拒绝响应,并标明是「被拒绝」而不是「未经过闸门」。 */
121
+ function denyAdmission(res, status, error) {
122
+ if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "denied");
123
+ json(res, status, { ok: false, error, admission: "denied" });
124
+ }
125
+
126
+ /**
127
+ * connection 准入(宿主信任边界)。**默认拒绝**。
90
128
  *
91
129
  * DSH 的 `connection` 服务负责插件 HTTP 路由的信任校验:`admit(request)` 先做
92
130
  * Host/Origin 校验,再做浏览器会话 / API 认证,返回本次调用对应的 Peer,或用
93
- * `401`(未认证)/`403`(不可信来源)拒绝 —— 宿主自己的 `/api` 路由用的就是它。
131
+ * `401`(未认证)/`403`(不可信来源)/`409`(连接已被占用)拒绝 —— 宿主自己的
132
+ * `/api` 路由用的就是它。
94
133
  *
95
- * 退回策略(只有一条,且完全不涉及凭据):宿主没有 `connection` 服务,
96
- * 或者请求**没有携带任何连接凭据**、来自回环地址时,按旧的「回环」规则放行,
97
- * 并在响应头标注 `X-DSH-Admission: loopback-legacy`。
98
- * 带凭据的请求一律以 admission 的结果为准;非回环请求一律拒绝。
134
+ * 规则(按顺序):
135
+ * 1. 宿主有 `connection`:一律以 `admit()` 为准,通过就放行;
136
+ * 只有「401 + 回环 + 没有任何凭据 + 用户显式开启兼容模式」这一种情况例外,
137
+ * 否则按 `admit()` 给出的状态码拒绝。
138
+ * 2. 宿主没有 `connection`:**默认拒绝**(403)。只有用户显式开启兼容模式、
139
+ * 且请求来自回环地址时才放行。
99
140
  *
100
141
  * @returns {boolean} 已放行 true;已写出拒绝响应 false
101
142
  */
102
- export function admissionGate(req, res, connection) {
143
+ export function admissionGate(req, res, connection, options = {}) {
103
144
  const loopback = isLoopback(req);
145
+ const optIn = options.allowLegacyLoopback === true;
104
146
  if (connection && typeof connection.admit === "function") {
105
147
  let admission;
106
148
  try {
107
149
  admission = connection.admit(req);
108
150
  } catch (err) {
109
- json(res, 500, { ok: false, error: `连接准入失败:${String((err && err.message) || err)}` });
151
+ denyAdmission(res, 500, `连接准入失败:${String((err && err.message) || err)}`);
152
+ return false;
153
+ }
154
+ if (!admission || typeof admission !== "object") {
155
+ // 宿主实现异常时宁可拒绝,也不要把「看不懂的结果」当成放行
156
+ denyAdmission(res, 500, "连接准入返回了无法识别的结果,按默认拒绝处理");
110
157
  return false;
111
158
  }
112
- if (!admission || typeof admission.rejection !== "number") return true;
113
- if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req)) {
114
- // 宿主自己的载体(Electron 经 IPC 桥接的 fetch)与本机脚本都不带 Cookie,
115
- // 没有可核验的凭据,只能按回环信任放行 —— 与接入准入之前的行为一致。
116
- if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "loopback-legacy");
159
+ if (!("rejection" in admission)) return true;
160
+ if (typeof admission.rejection !== "number") {
161
+ denyAdmission(res, 403, "连接准入返回了无法识别的拒绝码,按默认拒绝处理");
162
+ return false;
163
+ }
164
+ if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req) && optIn) {
165
+ // 例外路径:宿主自己的载体(Electron 经 IPC 桥接的 fetch)与本机脚本都不带
166
+ // Cookie,没有可核验的凭据。只有用户显式开启兼容模式才退回回环信任。
167
+ markLegacyAdmission(res);
117
168
  return true;
118
169
  }
119
- res.writeHead(admission.rejection, { "Content-Type": "text/plain; charset=utf-8" });
120
- res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
170
+ const status = admission.rejection === 403 ? 403 : admission.rejection === 409 ? 409 : 401;
171
+ denyAdmission(
172
+ res,
173
+ status,
174
+ status === 401
175
+ ? "请求未通过 DSH connection 认证:缺少有效的会话 Cookie / API 凭据"
176
+ : status === 409
177
+ ? "请求对应的连接已被占用"
178
+ : "请求来源不受信任:Host/Origin 校验未通过"
179
+ );
121
180
  return false;
122
181
  }
123
- if (!loopback) {
124
- json(res, 403, { ok: false, error: "只接受本机请求(loopback):当前宿主没有可用的 connection 准入服务" });
125
- return false;
182
+ if (optIn && loopback) {
183
+ markLegacyAdmission(res);
184
+ return true;
126
185
  }
127
- if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "loopback-legacy");
128
- return true;
186
+ denyAdmission(
187
+ res,
188
+ 403,
189
+ optIn
190
+ ? "只接受本机(loopback)请求:当前宿主没有可用的 connection 准入服务"
191
+ : "拒绝访问:当前宿主没有 connection 准入服务,兼容模式(仅回环放行)未开启,按默认拒绝处理"
192
+ );
193
+ return false;
129
194
  }
130
195
 
131
196
  /**
@@ -343,16 +408,30 @@ function openFolder(path) {
343
408
 
344
409
  export default {
345
410
  name: "dsh-app-updater",
346
- apply(ctx) {
411
+ apply(ctx, config) {
412
+ // 兼容模式:默认关闭。只有用户显式写进插件配置或环境变量才开启(见 LEGACY_LOOPBACK_ENV)。
413
+ const allowLegacyLoopback = resolveLegacyLoopbackOptIn(config);
414
+
347
415
  ctx.effect(() => {
348
- store.load().catch(() => {});
416
+ store
417
+ .load()
418
+ .then(() => {
419
+ if (allowLegacyLoopback) {
420
+ store.log(
421
+ "warn",
422
+ "兼容模式(allowLegacyLoopback)已开启:没有连接凭据的回环请求会按本机信任放行;" +
423
+ "建议只在旧宿主(没有 connection 服务)上临时使用"
424
+ );
425
+ }
426
+ })
427
+ .catch(() => {});
349
428
  }, "dsh-app-updater: load state");
350
429
 
351
430
  ctx.inject(["webServer"], (webCtx) => {
352
431
  const webServer = webCtx.webServer;
353
432
 
354
433
  // connection 准入:宿主提供的信任边界(Host/Origin 校验 + 浏览器/API 认证)。
355
- // 它在宿主没有 Web 载体时不存在,此时 admissionGate 退回旧的「回环」检查。
434
+ // 它在宿主没有 Web 载体时不存在,此时 admissionGate 默认拒绝,除非兼容模式显式开启。
356
435
  let connection = null;
357
436
  ctx.inject(["connection"], (connCtx) => {
358
437
  connection = connCtx.connection;
@@ -362,6 +441,22 @@ export default {
362
441
  });
363
442
  const routes = [];
364
443
 
444
+ /** 准入现状:写进状态接口,界面/复核者不必读代码就知道当前走的是哪条路径。 */
445
+ const admissionInfo = () => ({
446
+ mode: connection
447
+ ? allowLegacyLoopback
448
+ ? "connection+loopback-legacy"
449
+ : "connection"
450
+ : allowLegacyLoopback
451
+ ? "loopback-legacy-optin"
452
+ : "deny",
453
+ service: Boolean(connection),
454
+ allowLegacyLoopback,
455
+ warning: allowLegacyLoopback
456
+ ? "兼容模式已开启:没有连接凭据的本机回环请求会被放行,仅应在旧宿主上临时使用"
457
+ : null,
458
+ });
459
+
365
460
  /**
366
461
  * 注册一条路由:请求先过 connection 准入(见 admissionGate),再进业务处理。
367
462
  * 写接口另有 `confirm:true` 闸门(writeGate)。
@@ -372,7 +467,7 @@ export default {
372
467
  kind: "exact",
373
468
  path: `${PREFIX}${path}`,
374
469
  handler: (req, res) => {
375
- if (!admissionGate(req, res, connection)) return;
470
+ if (!admissionGate(req, res, connection, { allowLegacyLoopback })) return;
376
471
  return handler(req, res);
377
472
  },
378
473
  })
@@ -382,7 +477,7 @@ export default {
382
477
  register("/state.json", async (req, res) => {
383
478
  try {
384
479
  await store.load();
385
- json(res, 200, buildStatePayload());
480
+ json(res, 200, { ...buildStatePayload(), admission: admissionInfo() });
386
481
  } catch (err) {
387
482
  json(res, 500, { ok: false, error: String((err && err.message) || err) });
388
483
  }
@@ -479,6 +574,7 @@ export default {
479
574
  envSummary: describeEnvironment(env),
480
575
  engine,
481
576
  capabilities: capabilitiesFor(env, Boolean(engine.available)),
577
+ admission: admissionInfo(),
482
578
  routes: routes.length,
483
579
  dataDir: join(process.env.DSH_HOME || join(homedir(), ".dsh"), "dsh-app-updater"),
484
580
  });