dsh-app-updater 1.3.2 → 1.3.4
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 +293 -191
- package/CONTRACT.md +150 -150
- package/LICENSE +20 -20
- package/README.en.md +243 -239
- package/README.md +275 -253
- package/RELEASING.md +75 -75
- package/cordis.patch.yml +8 -8
- package/icon.svg +32 -32
- package/lib/client.js +1491 -1460
- package/lib/elevate.js +204 -204
- package/lib/engine.js +1315 -1282
- package/lib/index.js +597 -597
- package/lib/macos.js +494 -409
- package/lib/platform.js +199 -199
- package/lib/proc.js +91 -91
- package/lib/registry.js +120 -120
- package/lib/state.js +141 -141
- package/lib/version.js +81 -81
- package/lib/winget.js +337 -337
- package/package.json +68 -68
- package/test/deny.mjs +148 -148
- package/test/harness.mjs +125 -125
- package/test/render.mjs +613 -570
- package/test/unit.mjs +590 -552
package/lib/index.js
CHANGED
|
@@ -1,597 +1,597 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-app-updater 宿主半身。
|
|
3
|
-
*
|
|
4
|
-
* 职责:暴露 HTTP 路由给设置页 UI,串行调度「扫描」与「更新」两条后台任务,
|
|
5
|
-
* 把进度写进持久化状态。所有路由默认拒绝:先过宿主 connection 准入;写操作另需
|
|
6
|
-
* confirm:true。仅在用户显式开启兼容模式时,才按「回环地址」放行(默认关闭)。
|
|
7
|
-
*
|
|
8
|
-
* 设计上不依赖宿主内部服务(不用 llm / tools / agent),只依赖 webServer,
|
|
9
|
-
* 因此在没有 Web 的 composition 里它会安静地不挂载任何路由。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
import { existsSync } from "node:fs";
|
|
15
|
-
import { spawn } from "node:child_process";
|
|
16
|
-
import { createRequire } from "node:module";
|
|
17
|
-
|
|
18
|
-
import { Store } from "./state.js";
|
|
19
|
-
import { scanApps, runUpdates, resolveDownloadDir, findBrew, currentEnvironment } from "./engine.js";
|
|
20
|
-
import { findWinget } from "./winget.js";
|
|
21
|
-
import { describeEnvironment, engineFor, capabilitiesFor, ENGINE_WINGET, ENGINE_BREW } from "./platform.js";
|
|
22
|
-
|
|
23
|
-
const PREFIX = "/dsh-app-updater";
|
|
24
|
-
const MAX_BODY_BYTES = 256 * 1024;
|
|
25
|
-
|
|
26
|
-
/** 版本号只有 package.json 一个来源,避免两处写死对不上。 */
|
|
27
|
-
const PLUGIN_VERSION = (() => {
|
|
28
|
-
try {
|
|
29
|
-
return createRequire(import.meta.url)("../package.json").version || "";
|
|
30
|
-
} catch {
|
|
31
|
-
return "";
|
|
32
|
-
}
|
|
33
|
-
})();
|
|
34
|
-
|
|
35
|
-
const store = new Store();
|
|
36
|
-
|
|
37
|
-
/** 当前任务的控制面。同一时刻只允许一个扫描 + 一个更新。 */
|
|
38
|
-
const control = {
|
|
39
|
-
scanAbort: null,
|
|
40
|
-
updateAbort: null,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
function json(res, status, payload) {
|
|
44
|
-
const body = JSON.stringify(payload);
|
|
45
|
-
res.writeHead(status, {
|
|
46
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
47
|
-
"Cache-Control": "no-store",
|
|
48
|
-
"Content-Length": Buffer.byteLength(body),
|
|
49
|
-
});
|
|
50
|
-
res.end(body);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function isLoopback(req) {
|
|
54
|
-
const a = req && req.socket && req.socket.remoteAddress;
|
|
55
|
-
return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function readJsonBody(req) {
|
|
59
|
-
return new Promise((resolve) => {
|
|
60
|
-
let chunks = "";
|
|
61
|
-
let tooLarge = false;
|
|
62
|
-
req.on("data", (c) => {
|
|
63
|
-
if (tooLarge) return;
|
|
64
|
-
if (chunks.length + c.length > MAX_BODY_BYTES) {
|
|
65
|
-
tooLarge = true;
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
chunks += c;
|
|
69
|
-
});
|
|
70
|
-
req.on("end", () => {
|
|
71
|
-
if (tooLarge) return resolve({ tooLarge: true });
|
|
72
|
-
if (!chunks.trim()) return resolve({});
|
|
73
|
-
try {
|
|
74
|
-
resolve(JSON.parse(chunks));
|
|
75
|
-
} catch {
|
|
76
|
-
resolve({ parseError: true });
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
req.on("error", () => resolve({ parseError: true }));
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** 请求是否携带了 DSH 连接凭据(浏览器会话 Cookie 或 API Cookie)。 */
|
|
84
|
-
export function hasConnectionCredentials(req) {
|
|
85
|
-
const h = (req && req.headers) || {};
|
|
86
|
-
return Boolean(h.cookie || h.authorization);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
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 准入(宿主信任边界)。**默认拒绝**。
|
|
128
|
-
*
|
|
129
|
-
* DSH 的 `connection` 服务负责插件 HTTP 路由的信任校验:`admit(request)` 先做
|
|
130
|
-
* Host/Origin 校验,再做浏览器会话 / API 认证,返回本次调用对应的 Peer,或用
|
|
131
|
-
* `401`(未认证)/`403`(不可信来源)/`409`(连接已被占用)拒绝 —— 宿主自己的
|
|
132
|
-
* `/api` 路由用的就是它。
|
|
133
|
-
*
|
|
134
|
-
* 规则(按顺序):
|
|
135
|
-
* 1. 宿主有 `connection`:一律以 `admit()` 为准,通过就放行;
|
|
136
|
-
* 只有「401 + 回环 + 没有任何凭据 + 用户显式开启兼容模式」这一种情况例外,
|
|
137
|
-
* 否则按 `admit()` 给出的状态码拒绝。
|
|
138
|
-
* 2. 宿主没有 `connection`:**默认拒绝**(403)。只有用户显式开启兼容模式、
|
|
139
|
-
* 且请求来自回环地址时才放行。
|
|
140
|
-
*
|
|
141
|
-
* @returns {boolean} 已放行 true;已写出拒绝响应 false
|
|
142
|
-
*/
|
|
143
|
-
export function admissionGate(req, res, connection, options = {}) {
|
|
144
|
-
const loopback = isLoopback(req);
|
|
145
|
-
const optIn = options.allowLegacyLoopback === true;
|
|
146
|
-
if (connection && typeof connection.admit === "function") {
|
|
147
|
-
let admission;
|
|
148
|
-
try {
|
|
149
|
-
admission = connection.admit(req);
|
|
150
|
-
} catch (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, "连接准入返回了无法识别的结果,按默认拒绝处理");
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
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);
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
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
|
-
);
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
if (optIn && loopback) {
|
|
183
|
-
markLegacyAdmission(res);
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
denyAdmission(
|
|
187
|
-
res,
|
|
188
|
-
403,
|
|
189
|
-
optIn
|
|
190
|
-
? "只接受本机(loopback)请求:当前宿主没有可用的 connection 准入服务"
|
|
191
|
-
: "拒绝访问:当前宿主没有 connection 准入服务,兼容模式(仅回环放行)未开启,按默认拒绝处理"
|
|
192
|
-
);
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* 写操作闸门:`confirm:true`。
|
|
198
|
-
*
|
|
199
|
-
* 来源校验已经由 `register()` 里的 connection 准入做过(见 admissionGate),
|
|
200
|
-
* 这里只负责「这次写入是用户显式确认过的」。
|
|
201
|
-
*/
|
|
202
|
-
async function writeGate(req, res) {
|
|
203
|
-
const body = await readJsonBody(req);
|
|
204
|
-
if (body.tooLarge) {
|
|
205
|
-
json(res, 413, { ok: false, error: "请求体过大" });
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
if (body.parseError) {
|
|
209
|
-
json(res, 400, { ok: false, error: "请求体不是合法 JSON" });
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
if (body.confirm !== true) {
|
|
213
|
-
json(res, 400, { ok: false, error: "缺少 confirm:true(写操作必须显式确认)" });
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
return body;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function phaseOf(state) {
|
|
220
|
-
if (state.update.running) return "updating";
|
|
221
|
-
if (state.scan.running) return "scanning";
|
|
222
|
-
return "idle";
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* 平台闸门:Windows 与 macOS 通过;其它系统明确拒绝,而不是让引擎跑到一半才报错。
|
|
227
|
-
* @returns {object|null} 通过时返回环境对象,否则已写出 400 响应并返回 null
|
|
228
|
-
*/
|
|
229
|
-
function platformGuard(res) {
|
|
230
|
-
const env = currentEnvironment();
|
|
231
|
-
if (env.family === "windows" || env.family === "macos") return env;
|
|
232
|
-
json(res, 400, {
|
|
233
|
-
ok: false,
|
|
234
|
-
error: `当前系统 ${env.platform} 暂不支持(本插件面向 Windows 与 macOS)`,
|
|
235
|
-
});
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function buildStatePayload() {
|
|
240
|
-
const s = store.state;
|
|
241
|
-
const env = s.env || currentEnvironment();
|
|
242
|
-
const engine = s.engine && s.engine.id ? s.engine : defaultEngine(env);
|
|
243
|
-
return {
|
|
244
|
-
ok: true,
|
|
245
|
-
platform: env.platform,
|
|
246
|
-
env,
|
|
247
|
-
envSummary: describeEnvironment(env),
|
|
248
|
-
engine,
|
|
249
|
-
capabilities: capabilitiesFor(env, Boolean(engine.available)),
|
|
250
|
-
winget: s.winget || detectWingetSync(),
|
|
251
|
-
phase: phaseOf(s),
|
|
252
|
-
scan: s.scan,
|
|
253
|
-
update: s.update,
|
|
254
|
-
apps: s.apps,
|
|
255
|
-
log: s.log.slice(-200),
|
|
256
|
-
settings: s.settings,
|
|
257
|
-
downloadDir: resolveDownloadDir(s.settings),
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** 还没扫描过时,也把引擎探测出来,界面不必等到扫描完才显示环境。 */
|
|
262
|
-
let cachedDefaultEngine = null;
|
|
263
|
-
function defaultEngine(env) {
|
|
264
|
-
const id = engineFor(env.family);
|
|
265
|
-
if (cachedDefaultEngine && cachedDefaultEngine.id === id) return cachedDefaultEngine;
|
|
266
|
-
if (id === ENGINE_WINGET) {
|
|
267
|
-
const file = findWinget();
|
|
268
|
-
cachedDefaultEngine = { id, available: Boolean(file), version: "", path: file || "" };
|
|
269
|
-
} else if (id === ENGINE_BREW) {
|
|
270
|
-
const file = findBrew();
|
|
271
|
-
cachedDefaultEngine = { id, available: Boolean(file), version: "", path: file || "" };
|
|
272
|
-
} else {
|
|
273
|
-
cachedDefaultEngine = { id, available: false, version: "", path: "" };
|
|
274
|
-
}
|
|
275
|
-
return cachedDefaultEngine;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
let cachedWinget = null;
|
|
279
|
-
function detectWingetSync() {
|
|
280
|
-
if (cachedWinget) return cachedWinget;
|
|
281
|
-
const file = findWinget();
|
|
282
|
-
cachedWinget = { available: Boolean(file), version: "", path: file || "" };
|
|
283
|
-
return cachedWinget;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/* ------------------------------------------------------------------ *
|
|
287
|
-
* 任务
|
|
288
|
-
* ------------------------------------------------------------------ */
|
|
289
|
-
|
|
290
|
-
let scanPromise = null;
|
|
291
|
-
|
|
292
|
-
async function startScan(force) {
|
|
293
|
-
if (store.state.scan.running && scanPromise) return { started: false, running: true };
|
|
294
|
-
if (!force && store.state.apps.scannedAt && Date.now() - store.state.apps.scannedAt < 30_000) {
|
|
295
|
-
return { started: false, cached: true };
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const abort = new AbortController();
|
|
299
|
-
control.scanAbort = abort;
|
|
300
|
-
store.state.scan = { ...store.state.scan, running: true, startedAt: Date.now(), finishedAt: 0, error: null, warnings: [] };
|
|
301
|
-
store.save();
|
|
302
|
-
|
|
303
|
-
scanPromise = (async () => {
|
|
304
|
-
try {
|
|
305
|
-
const result = await scanApps({
|
|
306
|
-
settings: store.state.settings,
|
|
307
|
-
log: (level, text) => store.log(level, text),
|
|
308
|
-
signal: abort.signal,
|
|
309
|
-
});
|
|
310
|
-
store.state.env = result.env || store.state.env;
|
|
311
|
-
store.state.engine = result.engine || store.state.engine;
|
|
312
|
-
if (result.winget) store.state.winget = result.winget;
|
|
313
|
-
store.state.apps = {
|
|
314
|
-
scannedAt: result.scannedAt,
|
|
315
|
-
stats: result.stats,
|
|
316
|
-
items: result.items,
|
|
317
|
-
engine: result.engine ? result.engine.id : "",
|
|
318
|
-
};
|
|
319
|
-
store.state.scan = {
|
|
320
|
-
running: false,
|
|
321
|
-
startedAt: result.startedAt,
|
|
322
|
-
finishedAt: Date.now(),
|
|
323
|
-
error: null,
|
|
324
|
-
warnings: result.warnings,
|
|
325
|
-
};
|
|
326
|
-
} catch (err) {
|
|
327
|
-
const message = String((err && err.message) || err);
|
|
328
|
-
store.state.scan = { ...store.state.scan, running: false, finishedAt: Date.now(), error: message };
|
|
329
|
-
store.log("error", `扫描失败:${message}`);
|
|
330
|
-
} finally {
|
|
331
|
-
control.scanAbort = null;
|
|
332
|
-
scanPromise = null;
|
|
333
|
-
store.save();
|
|
334
|
-
}
|
|
335
|
-
})();
|
|
336
|
-
|
|
337
|
-
return { started: true };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
let updatePromise = null;
|
|
341
|
-
|
|
342
|
-
async function startUpdate(ids) {
|
|
343
|
-
if (store.state.update.running) return { started: false, running: true };
|
|
344
|
-
const list = (Array.isArray(ids) ? ids : []).filter((x) => typeof x === "string" && x.trim());
|
|
345
|
-
if (!list.length) return { started: false, error: "没有要更新的应用" };
|
|
346
|
-
|
|
347
|
-
const abort = new AbortController();
|
|
348
|
-
control.updateAbort = abort;
|
|
349
|
-
store.state.update = {
|
|
350
|
-
running: true,
|
|
351
|
-
startedAt: Date.now(),
|
|
352
|
-
finishedAt: 0,
|
|
353
|
-
total: list.length,
|
|
354
|
-
done: 0,
|
|
355
|
-
current: null,
|
|
356
|
-
results: [],
|
|
357
|
-
error: null,
|
|
358
|
-
};
|
|
359
|
-
store.save();
|
|
360
|
-
|
|
361
|
-
updatePromise = (async () => {
|
|
362
|
-
try {
|
|
363
|
-
await runUpdates({
|
|
364
|
-
ids: list,
|
|
365
|
-
// 把界面刚扫描出来的「已装版本 / 目标版本」一并带过去:安装后的复核要用它
|
|
366
|
-
// 判断这次更新到底有没有真的生效(只看退出码会把「静默退出 0 却什么都没装」误报成功)
|
|
367
|
-
plan: (store.state.apps.items || []).filter((it) => list.includes(it.id)),
|
|
368
|
-
settings: store.state.settings,
|
|
369
|
-
log: (level, text) => store.log(level, text),
|
|
370
|
-
signal: abort.signal,
|
|
371
|
-
onProgress: (patch) => {
|
|
372
|
-
store.state.update = { ...store.state.update, ...patch };
|
|
373
|
-
store.save();
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
|
-
} catch (err) {
|
|
377
|
-
const message = String((err && err.message) || err);
|
|
378
|
-
store.state.update = { ...store.state.update, running: false, finishedAt: Date.now(), error: message };
|
|
379
|
-
store.log("error", `更新任务异常:${message}`);
|
|
380
|
-
} finally {
|
|
381
|
-
control.updateAbort = null;
|
|
382
|
-
updatePromise = null;
|
|
383
|
-
store.save();
|
|
384
|
-
// 更新结束后自动重扫,让版本号刷新成新值
|
|
385
|
-
startScan(true).catch(() => {});
|
|
386
|
-
}
|
|
387
|
-
})();
|
|
388
|
-
|
|
389
|
-
return { started: true, queued: list.length };
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** 在资源管理器中打开下载目录(不需要 shell 解释器,避免注入)。 */
|
|
393
|
-
function openFolder(path) {
|
|
394
|
-
try {
|
|
395
|
-
const explorer = join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "explorer.exe");
|
|
396
|
-
const child = spawn(explorer, [path], { detached: true, stdio: "ignore", windowsHide: false });
|
|
397
|
-
child.on("error", () => {});
|
|
398
|
-
child.unref();
|
|
399
|
-
return true;
|
|
400
|
-
} catch {
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/* ------------------------------------------------------------------ *
|
|
406
|
-
* 插件
|
|
407
|
-
* ------------------------------------------------------------------ */
|
|
408
|
-
|
|
409
|
-
export default {
|
|
410
|
-
name: "dsh-app-updater",
|
|
411
|
-
apply(ctx, config) {
|
|
412
|
-
// 兼容模式:默认关闭。只有用户显式写进插件配置或环境变量才开启(见 LEGACY_LOOPBACK_ENV)。
|
|
413
|
-
const allowLegacyLoopback = resolveLegacyLoopbackOptIn(config);
|
|
414
|
-
|
|
415
|
-
ctx.effect(() => {
|
|
416
|
-
store
|
|
417
|
-
.load()
|
|
418
|
-
.then(() => {
|
|
419
|
-
if (allowLegacyLoopback) {
|
|
420
|
-
store.log(
|
|
421
|
-
"warn",
|
|
422
|
-
"兼容模式(allowLegacyLoopback)已开启:没有连接凭据的回环请求会按本机信任放行;" +
|
|
423
|
-
"建议只在旧宿主(没有 connection 服务)上临时使用"
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
})
|
|
427
|
-
.catch(() => {});
|
|
428
|
-
}, "dsh-app-updater: load state");
|
|
429
|
-
|
|
430
|
-
ctx.inject(["webServer"], (webCtx) => {
|
|
431
|
-
const webServer = webCtx.webServer;
|
|
432
|
-
|
|
433
|
-
// connection 准入:宿主提供的信任边界(Host/Origin 校验 + 浏览器/API 认证)。
|
|
434
|
-
// 它在宿主没有 Web 载体时不存在,此时 admissionGate 默认拒绝,除非兼容模式显式开启。
|
|
435
|
-
let connection = null;
|
|
436
|
-
ctx.inject(["connection"], (connCtx) => {
|
|
437
|
-
connection = connCtx.connection;
|
|
438
|
-
return () => {
|
|
439
|
-
connection = null;
|
|
440
|
-
};
|
|
441
|
-
});
|
|
442
|
-
const routes = [];
|
|
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
|
-
|
|
460
|
-
/**
|
|
461
|
-
* 注册一条路由:请求先过 connection 准入(见 admissionGate),再进业务处理。
|
|
462
|
-
* 写接口另有 `confirm:true` 闸门(writeGate)。
|
|
463
|
-
*/
|
|
464
|
-
const register = (path, handler) => {
|
|
465
|
-
routes.push(
|
|
466
|
-
webServer.register({
|
|
467
|
-
kind: "exact",
|
|
468
|
-
path: `${PREFIX}${path}`,
|
|
469
|
-
handler: (req, res) => {
|
|
470
|
-
if (!admissionGate(req, res, connection, { allowLegacyLoopback })) return;
|
|
471
|
-
return handler(req, res);
|
|
472
|
-
},
|
|
473
|
-
})
|
|
474
|
-
);
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
register("/state.json", async (req, res) => {
|
|
478
|
-
try {
|
|
479
|
-
await store.load();
|
|
480
|
-
json(res, 200, { ...buildStatePayload(), admission: admissionInfo() });
|
|
481
|
-
} catch (err) {
|
|
482
|
-
json(res, 500, { ok: false, error: String((err && err.message) || err) });
|
|
483
|
-
}
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
register("/apps.json", async (req, res) => {
|
|
487
|
-
await store.load();
|
|
488
|
-
json(res, 200, {
|
|
489
|
-
ok: true,
|
|
490
|
-
scannedAt: store.state.apps.scannedAt,
|
|
491
|
-
stats: store.state.apps.stats,
|
|
492
|
-
items: store.state.apps.items,
|
|
493
|
-
warnings: store.state.scan.warnings || [],
|
|
494
|
-
});
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
register("/scan", async (req, res) => {
|
|
498
|
-
const body = await writeGate(req, res);
|
|
499
|
-
if (!body) return;
|
|
500
|
-
await store.load();
|
|
501
|
-
if (!platformGuard(res)) return;
|
|
502
|
-
const r = await startScan(body.force !== false);
|
|
503
|
-
json(res, 200, { ok: true, ...r });
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
register("/update", async (req, res) => {
|
|
507
|
-
const body = await writeGate(req, res);
|
|
508
|
-
if (!body) return;
|
|
509
|
-
await store.load();
|
|
510
|
-
if (!platformGuard(res)) return;
|
|
511
|
-
const r = await startUpdate(body.ids);
|
|
512
|
-
if (r.error) return json(res, 400, { ok: false, ...r });
|
|
513
|
-
json(res, 200, { ok: true, ...r });
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
register("/cancel", async (req, res) => {
|
|
517
|
-
const body = await writeGate(req, res);
|
|
518
|
-
if (!body) return;
|
|
519
|
-
await store.load();
|
|
520
|
-
let cancelled = false;
|
|
521
|
-
if (control.updateAbort) {
|
|
522
|
-
control.updateAbort.abort();
|
|
523
|
-
cancelled = true;
|
|
524
|
-
store.log("warn", "收到取消请求,正在停止更新队列");
|
|
525
|
-
}
|
|
526
|
-
if (control.scanAbort) {
|
|
527
|
-
control.scanAbort.abort();
|
|
528
|
-
cancelled = true;
|
|
529
|
-
store.log("warn", "收到取消请求,正在停止扫描");
|
|
530
|
-
}
|
|
531
|
-
json(res, 200, { ok: true, cancelled });
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
register("/settings", async (req, res) => {
|
|
535
|
-
const body = await writeGate(req, res);
|
|
536
|
-
if (!body) return;
|
|
537
|
-
await store.load();
|
|
538
|
-
const patch = { ...(body.settings || {}) };
|
|
539
|
-
if (Object.prototype.hasOwnProperty.call(patch, "downloadDir")) {
|
|
540
|
-
const value = String(patch.downloadDir == null ? "" : patch.downloadDir).trim();
|
|
541
|
-
// 空 = 用默认目录;否则必须是绝对路径,避免把安装包写到意料之外的位置
|
|
542
|
-
if (value && !/^[A-Za-z]:[\\/]/.test(value) && !/^\\\\[^\\]/.test(value)) {
|
|
543
|
-
return json(res, 400, {
|
|
544
|
-
ok: false,
|
|
545
|
-
error: "downloadDir 必须是绝对路径(如 D:\\Apps)或 UNC 路径(\\\\server\\share)",
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
patch.downloadDir = value;
|
|
549
|
-
}
|
|
550
|
-
const next = store.updateSettings(patch);
|
|
551
|
-
store.log("info", "设置已保存");
|
|
552
|
-
json(res, 200, { ok: true, settings: next, downloadDir: resolveDownloadDir(next) });
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
register("/open-downloads", async (req, res) => {
|
|
556
|
-
const body = await writeGate(req, res);
|
|
557
|
-
if (!body) return;
|
|
558
|
-
await store.load();
|
|
559
|
-
const dir = resolveDownloadDir(store.state.settings);
|
|
560
|
-
const opened = process.platform === "win32" ? openFolder(dir) : false;
|
|
561
|
-
json(res, 200, { ok: opened, path: dir, exists: existsSync(dir) });
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
register("/health.json", async (req, res) => {
|
|
565
|
-
await store.load();
|
|
566
|
-
const env = store.state.env || currentEnvironment();
|
|
567
|
-
const engine = store.state.engine && store.state.engine.id ? store.state.engine : defaultEngine(env);
|
|
568
|
-
json(res, 200, {
|
|
569
|
-
ok: true,
|
|
570
|
-
plugin: "dsh-app-updater",
|
|
571
|
-
version: PLUGIN_VERSION,
|
|
572
|
-
platform: env.platform,
|
|
573
|
-
env,
|
|
574
|
-
envSummary: describeEnvironment(env),
|
|
575
|
-
engine,
|
|
576
|
-
capabilities: capabilitiesFor(env, Boolean(engine.available)),
|
|
577
|
-
admission: admissionInfo(),
|
|
578
|
-
routes: routes.length,
|
|
579
|
-
dataDir: join(process.env.DSH_HOME || join(homedir(), ".dsh"), "dsh-app-updater"),
|
|
580
|
-
});
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
ctx.effect(
|
|
584
|
-
() => () => {
|
|
585
|
-
for (const dispose of routes) {
|
|
586
|
-
try {
|
|
587
|
-
dispose();
|
|
588
|
-
} catch {
|
|
589
|
-
/* 已释放 */
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
},
|
|
593
|
-
"dsh-app-updater: routes"
|
|
594
|
-
);
|
|
595
|
-
});
|
|
596
|
-
},
|
|
597
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* dsh-app-updater 宿主半身。
|
|
3
|
+
*
|
|
4
|
+
* 职责:暴露 HTTP 路由给设置页 UI,串行调度「扫描」与「更新」两条后台任务,
|
|
5
|
+
* 把进度写进持久化状态。所有路由默认拒绝:先过宿主 connection 准入;写操作另需
|
|
6
|
+
* confirm:true。仅在用户显式开启兼容模式时,才按「回环地址」放行(默认关闭)。
|
|
7
|
+
*
|
|
8
|
+
* 设计上不依赖宿主内部服务(不用 llm / tools / agent),只依赖 webServer,
|
|
9
|
+
* 因此在没有 Web 的 composition 里它会安静地不挂载任何路由。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
|
|
18
|
+
import { Store } from "./state.js";
|
|
19
|
+
import { scanApps, runUpdates, resolveDownloadDir, findBrew, currentEnvironment } from "./engine.js";
|
|
20
|
+
import { findWinget } from "./winget.js";
|
|
21
|
+
import { describeEnvironment, engineFor, capabilitiesFor, ENGINE_WINGET, ENGINE_BREW } from "./platform.js";
|
|
22
|
+
|
|
23
|
+
const PREFIX = "/dsh-app-updater";
|
|
24
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
25
|
+
|
|
26
|
+
/** 版本号只有 package.json 一个来源,避免两处写死对不上。 */
|
|
27
|
+
const PLUGIN_VERSION = (() => {
|
|
28
|
+
try {
|
|
29
|
+
return createRequire(import.meta.url)("../package.json").version || "";
|
|
30
|
+
} catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const store = new Store();
|
|
36
|
+
|
|
37
|
+
/** 当前任务的控制面。同一时刻只允许一个扫描 + 一个更新。 */
|
|
38
|
+
const control = {
|
|
39
|
+
scanAbort: null,
|
|
40
|
+
updateAbort: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function json(res, status, payload) {
|
|
44
|
+
const body = JSON.stringify(payload);
|
|
45
|
+
res.writeHead(status, {
|
|
46
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
47
|
+
"Cache-Control": "no-store",
|
|
48
|
+
"Content-Length": Buffer.byteLength(body),
|
|
49
|
+
});
|
|
50
|
+
res.end(body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isLoopback(req) {
|
|
54
|
+
const a = req && req.socket && req.socket.remoteAddress;
|
|
55
|
+
return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readJsonBody(req) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let chunks = "";
|
|
61
|
+
let tooLarge = false;
|
|
62
|
+
req.on("data", (c) => {
|
|
63
|
+
if (tooLarge) return;
|
|
64
|
+
if (chunks.length + c.length > MAX_BODY_BYTES) {
|
|
65
|
+
tooLarge = true;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks += c;
|
|
69
|
+
});
|
|
70
|
+
req.on("end", () => {
|
|
71
|
+
if (tooLarge) return resolve({ tooLarge: true });
|
|
72
|
+
if (!chunks.trim()) return resolve({});
|
|
73
|
+
try {
|
|
74
|
+
resolve(JSON.parse(chunks));
|
|
75
|
+
} catch {
|
|
76
|
+
resolve({ parseError: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
req.on("error", () => resolve({ parseError: true }));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 请求是否携带了 DSH 连接凭据(浏览器会话 Cookie 或 API Cookie)。 */
|
|
84
|
+
export function hasConnectionCredentials(req) {
|
|
85
|
+
const h = (req && req.headers) || {};
|
|
86
|
+
return Boolean(h.cookie || h.authorization);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
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 准入(宿主信任边界)。**默认拒绝**。
|
|
128
|
+
*
|
|
129
|
+
* DSH 的 `connection` 服务负责插件 HTTP 路由的信任校验:`admit(request)` 先做
|
|
130
|
+
* Host/Origin 校验,再做浏览器会话 / API 认证,返回本次调用对应的 Peer,或用
|
|
131
|
+
* `401`(未认证)/`403`(不可信来源)/`409`(连接已被占用)拒绝 —— 宿主自己的
|
|
132
|
+
* `/api` 路由用的就是它。
|
|
133
|
+
*
|
|
134
|
+
* 规则(按顺序):
|
|
135
|
+
* 1. 宿主有 `connection`:一律以 `admit()` 为准,通过就放行;
|
|
136
|
+
* 只有「401 + 回环 + 没有任何凭据 + 用户显式开启兼容模式」这一种情况例外,
|
|
137
|
+
* 否则按 `admit()` 给出的状态码拒绝。
|
|
138
|
+
* 2. 宿主没有 `connection`:**默认拒绝**(403)。只有用户显式开启兼容模式、
|
|
139
|
+
* 且请求来自回环地址时才放行。
|
|
140
|
+
*
|
|
141
|
+
* @returns {boolean} 已放行 true;已写出拒绝响应 false
|
|
142
|
+
*/
|
|
143
|
+
export function admissionGate(req, res, connection, options = {}) {
|
|
144
|
+
const loopback = isLoopback(req);
|
|
145
|
+
const optIn = options.allowLegacyLoopback === true;
|
|
146
|
+
if (connection && typeof connection.admit === "function") {
|
|
147
|
+
let admission;
|
|
148
|
+
try {
|
|
149
|
+
admission = connection.admit(req);
|
|
150
|
+
} catch (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, "连接准入返回了无法识别的结果,按默认拒绝处理");
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
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);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
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
|
+
);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (optIn && loopback) {
|
|
183
|
+
markLegacyAdmission(res);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
denyAdmission(
|
|
187
|
+
res,
|
|
188
|
+
403,
|
|
189
|
+
optIn
|
|
190
|
+
? "只接受本机(loopback)请求:当前宿主没有可用的 connection 准入服务"
|
|
191
|
+
: "拒绝访问:当前宿主没有 connection 准入服务,兼容模式(仅回环放行)未开启,按默认拒绝处理"
|
|
192
|
+
);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 写操作闸门:`confirm:true`。
|
|
198
|
+
*
|
|
199
|
+
* 来源校验已经由 `register()` 里的 connection 准入做过(见 admissionGate),
|
|
200
|
+
* 这里只负责「这次写入是用户显式确认过的」。
|
|
201
|
+
*/
|
|
202
|
+
async function writeGate(req, res) {
|
|
203
|
+
const body = await readJsonBody(req);
|
|
204
|
+
if (body.tooLarge) {
|
|
205
|
+
json(res, 413, { ok: false, error: "请求体过大" });
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (body.parseError) {
|
|
209
|
+
json(res, 400, { ok: false, error: "请求体不是合法 JSON" });
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (body.confirm !== true) {
|
|
213
|
+
json(res, 400, { ok: false, error: "缺少 confirm:true(写操作必须显式确认)" });
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
return body;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function phaseOf(state) {
|
|
220
|
+
if (state.update.running) return "updating";
|
|
221
|
+
if (state.scan.running) return "scanning";
|
|
222
|
+
return "idle";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 平台闸门:Windows 与 macOS 通过;其它系统明确拒绝,而不是让引擎跑到一半才报错。
|
|
227
|
+
* @returns {object|null} 通过时返回环境对象,否则已写出 400 响应并返回 null
|
|
228
|
+
*/
|
|
229
|
+
function platformGuard(res) {
|
|
230
|
+
const env = currentEnvironment();
|
|
231
|
+
if (env.family === "windows" || env.family === "macos") return env;
|
|
232
|
+
json(res, 400, {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: `当前系统 ${env.platform} 暂不支持(本插件面向 Windows 与 macOS)`,
|
|
235
|
+
});
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildStatePayload() {
|
|
240
|
+
const s = store.state;
|
|
241
|
+
const env = s.env || currentEnvironment();
|
|
242
|
+
const engine = s.engine && s.engine.id ? s.engine : defaultEngine(env);
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
platform: env.platform,
|
|
246
|
+
env,
|
|
247
|
+
envSummary: describeEnvironment(env),
|
|
248
|
+
engine,
|
|
249
|
+
capabilities: capabilitiesFor(env, Boolean(engine.available)),
|
|
250
|
+
winget: s.winget || detectWingetSync(),
|
|
251
|
+
phase: phaseOf(s),
|
|
252
|
+
scan: s.scan,
|
|
253
|
+
update: s.update,
|
|
254
|
+
apps: s.apps,
|
|
255
|
+
log: s.log.slice(-200),
|
|
256
|
+
settings: s.settings,
|
|
257
|
+
downloadDir: resolveDownloadDir(s.settings),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 还没扫描过时,也把引擎探测出来,界面不必等到扫描完才显示环境。 */
|
|
262
|
+
let cachedDefaultEngine = null;
|
|
263
|
+
function defaultEngine(env) {
|
|
264
|
+
const id = engineFor(env.family);
|
|
265
|
+
if (cachedDefaultEngine && cachedDefaultEngine.id === id) return cachedDefaultEngine;
|
|
266
|
+
if (id === ENGINE_WINGET) {
|
|
267
|
+
const file = findWinget();
|
|
268
|
+
cachedDefaultEngine = { id, available: Boolean(file), version: "", path: file || "" };
|
|
269
|
+
} else if (id === ENGINE_BREW) {
|
|
270
|
+
const file = findBrew();
|
|
271
|
+
cachedDefaultEngine = { id, available: Boolean(file), version: "", path: file || "" };
|
|
272
|
+
} else {
|
|
273
|
+
cachedDefaultEngine = { id, available: false, version: "", path: "" };
|
|
274
|
+
}
|
|
275
|
+
return cachedDefaultEngine;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let cachedWinget = null;
|
|
279
|
+
function detectWingetSync() {
|
|
280
|
+
if (cachedWinget) return cachedWinget;
|
|
281
|
+
const file = findWinget();
|
|
282
|
+
cachedWinget = { available: Boolean(file), version: "", path: file || "" };
|
|
283
|
+
return cachedWinget;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ------------------------------------------------------------------ *
|
|
287
|
+
* 任务
|
|
288
|
+
* ------------------------------------------------------------------ */
|
|
289
|
+
|
|
290
|
+
let scanPromise = null;
|
|
291
|
+
|
|
292
|
+
async function startScan(force) {
|
|
293
|
+
if (store.state.scan.running && scanPromise) return { started: false, running: true };
|
|
294
|
+
if (!force && store.state.apps.scannedAt && Date.now() - store.state.apps.scannedAt < 30_000) {
|
|
295
|
+
return { started: false, cached: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const abort = new AbortController();
|
|
299
|
+
control.scanAbort = abort;
|
|
300
|
+
store.state.scan = { ...store.state.scan, running: true, startedAt: Date.now(), finishedAt: 0, error: null, warnings: [] };
|
|
301
|
+
store.save();
|
|
302
|
+
|
|
303
|
+
scanPromise = (async () => {
|
|
304
|
+
try {
|
|
305
|
+
const result = await scanApps({
|
|
306
|
+
settings: store.state.settings,
|
|
307
|
+
log: (level, text) => store.log(level, text),
|
|
308
|
+
signal: abort.signal,
|
|
309
|
+
});
|
|
310
|
+
store.state.env = result.env || store.state.env;
|
|
311
|
+
store.state.engine = result.engine || store.state.engine;
|
|
312
|
+
if (result.winget) store.state.winget = result.winget;
|
|
313
|
+
store.state.apps = {
|
|
314
|
+
scannedAt: result.scannedAt,
|
|
315
|
+
stats: result.stats,
|
|
316
|
+
items: result.items,
|
|
317
|
+
engine: result.engine ? result.engine.id : "",
|
|
318
|
+
};
|
|
319
|
+
store.state.scan = {
|
|
320
|
+
running: false,
|
|
321
|
+
startedAt: result.startedAt,
|
|
322
|
+
finishedAt: Date.now(),
|
|
323
|
+
error: null,
|
|
324
|
+
warnings: result.warnings,
|
|
325
|
+
};
|
|
326
|
+
} catch (err) {
|
|
327
|
+
const message = String((err && err.message) || err);
|
|
328
|
+
store.state.scan = { ...store.state.scan, running: false, finishedAt: Date.now(), error: message };
|
|
329
|
+
store.log("error", `扫描失败:${message}`);
|
|
330
|
+
} finally {
|
|
331
|
+
control.scanAbort = null;
|
|
332
|
+
scanPromise = null;
|
|
333
|
+
store.save();
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
336
|
+
|
|
337
|
+
return { started: true };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let updatePromise = null;
|
|
341
|
+
|
|
342
|
+
async function startUpdate(ids) {
|
|
343
|
+
if (store.state.update.running) return { started: false, running: true };
|
|
344
|
+
const list = (Array.isArray(ids) ? ids : []).filter((x) => typeof x === "string" && x.trim());
|
|
345
|
+
if (!list.length) return { started: false, error: "没有要更新的应用" };
|
|
346
|
+
|
|
347
|
+
const abort = new AbortController();
|
|
348
|
+
control.updateAbort = abort;
|
|
349
|
+
store.state.update = {
|
|
350
|
+
running: true,
|
|
351
|
+
startedAt: Date.now(),
|
|
352
|
+
finishedAt: 0,
|
|
353
|
+
total: list.length,
|
|
354
|
+
done: 0,
|
|
355
|
+
current: null,
|
|
356
|
+
results: [],
|
|
357
|
+
error: null,
|
|
358
|
+
};
|
|
359
|
+
store.save();
|
|
360
|
+
|
|
361
|
+
updatePromise = (async () => {
|
|
362
|
+
try {
|
|
363
|
+
await runUpdates({
|
|
364
|
+
ids: list,
|
|
365
|
+
// 把界面刚扫描出来的「已装版本 / 目标版本」一并带过去:安装后的复核要用它
|
|
366
|
+
// 判断这次更新到底有没有真的生效(只看退出码会把「静默退出 0 却什么都没装」误报成功)
|
|
367
|
+
plan: (store.state.apps.items || []).filter((it) => list.includes(it.id)),
|
|
368
|
+
settings: store.state.settings,
|
|
369
|
+
log: (level, text) => store.log(level, text),
|
|
370
|
+
signal: abort.signal,
|
|
371
|
+
onProgress: (patch) => {
|
|
372
|
+
store.state.update = { ...store.state.update, ...patch };
|
|
373
|
+
store.save();
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
} catch (err) {
|
|
377
|
+
const message = String((err && err.message) || err);
|
|
378
|
+
store.state.update = { ...store.state.update, running: false, finishedAt: Date.now(), error: message };
|
|
379
|
+
store.log("error", `更新任务异常:${message}`);
|
|
380
|
+
} finally {
|
|
381
|
+
control.updateAbort = null;
|
|
382
|
+
updatePromise = null;
|
|
383
|
+
store.save();
|
|
384
|
+
// 更新结束后自动重扫,让版本号刷新成新值
|
|
385
|
+
startScan(true).catch(() => {});
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
|
|
389
|
+
return { started: true, queued: list.length };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 在资源管理器中打开下载目录(不需要 shell 解释器,避免注入)。 */
|
|
393
|
+
function openFolder(path) {
|
|
394
|
+
try {
|
|
395
|
+
const explorer = join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "explorer.exe");
|
|
396
|
+
const child = spawn(explorer, [path], { detached: true, stdio: "ignore", windowsHide: false });
|
|
397
|
+
child.on("error", () => {});
|
|
398
|
+
child.unref();
|
|
399
|
+
return true;
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* ------------------------------------------------------------------ *
|
|
406
|
+
* 插件
|
|
407
|
+
* ------------------------------------------------------------------ */
|
|
408
|
+
|
|
409
|
+
export default {
|
|
410
|
+
name: "dsh-app-updater",
|
|
411
|
+
apply(ctx, config) {
|
|
412
|
+
// 兼容模式:默认关闭。只有用户显式写进插件配置或环境变量才开启(见 LEGACY_LOOPBACK_ENV)。
|
|
413
|
+
const allowLegacyLoopback = resolveLegacyLoopbackOptIn(config);
|
|
414
|
+
|
|
415
|
+
ctx.effect(() => {
|
|
416
|
+
store
|
|
417
|
+
.load()
|
|
418
|
+
.then(() => {
|
|
419
|
+
if (allowLegacyLoopback) {
|
|
420
|
+
store.log(
|
|
421
|
+
"warn",
|
|
422
|
+
"兼容模式(allowLegacyLoopback)已开启:没有连接凭据的回环请求会按本机信任放行;" +
|
|
423
|
+
"建议只在旧宿主(没有 connection 服务)上临时使用"
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
.catch(() => {});
|
|
428
|
+
}, "dsh-app-updater: load state");
|
|
429
|
+
|
|
430
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
431
|
+
const webServer = webCtx.webServer;
|
|
432
|
+
|
|
433
|
+
// connection 准入:宿主提供的信任边界(Host/Origin 校验 + 浏览器/API 认证)。
|
|
434
|
+
// 它在宿主没有 Web 载体时不存在,此时 admissionGate 默认拒绝,除非兼容模式显式开启。
|
|
435
|
+
let connection = null;
|
|
436
|
+
ctx.inject(["connection"], (connCtx) => {
|
|
437
|
+
connection = connCtx.connection;
|
|
438
|
+
return () => {
|
|
439
|
+
connection = null;
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
const routes = [];
|
|
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
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 注册一条路由:请求先过 connection 准入(见 admissionGate),再进业务处理。
|
|
462
|
+
* 写接口另有 `confirm:true` 闸门(writeGate)。
|
|
463
|
+
*/
|
|
464
|
+
const register = (path, handler) => {
|
|
465
|
+
routes.push(
|
|
466
|
+
webServer.register({
|
|
467
|
+
kind: "exact",
|
|
468
|
+
path: `${PREFIX}${path}`,
|
|
469
|
+
handler: (req, res) => {
|
|
470
|
+
if (!admissionGate(req, res, connection, { allowLegacyLoopback })) return;
|
|
471
|
+
return handler(req, res);
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
register("/state.json", async (req, res) => {
|
|
478
|
+
try {
|
|
479
|
+
await store.load();
|
|
480
|
+
json(res, 200, { ...buildStatePayload(), admission: admissionInfo() });
|
|
481
|
+
} catch (err) {
|
|
482
|
+
json(res, 500, { ok: false, error: String((err && err.message) || err) });
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
register("/apps.json", async (req, res) => {
|
|
487
|
+
await store.load();
|
|
488
|
+
json(res, 200, {
|
|
489
|
+
ok: true,
|
|
490
|
+
scannedAt: store.state.apps.scannedAt,
|
|
491
|
+
stats: store.state.apps.stats,
|
|
492
|
+
items: store.state.apps.items,
|
|
493
|
+
warnings: store.state.scan.warnings || [],
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
register("/scan", async (req, res) => {
|
|
498
|
+
const body = await writeGate(req, res);
|
|
499
|
+
if (!body) return;
|
|
500
|
+
await store.load();
|
|
501
|
+
if (!platformGuard(res)) return;
|
|
502
|
+
const r = await startScan(body.force !== false);
|
|
503
|
+
json(res, 200, { ok: true, ...r });
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
register("/update", async (req, res) => {
|
|
507
|
+
const body = await writeGate(req, res);
|
|
508
|
+
if (!body) return;
|
|
509
|
+
await store.load();
|
|
510
|
+
if (!platformGuard(res)) return;
|
|
511
|
+
const r = await startUpdate(body.ids);
|
|
512
|
+
if (r.error) return json(res, 400, { ok: false, ...r });
|
|
513
|
+
json(res, 200, { ok: true, ...r });
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
register("/cancel", async (req, res) => {
|
|
517
|
+
const body = await writeGate(req, res);
|
|
518
|
+
if (!body) return;
|
|
519
|
+
await store.load();
|
|
520
|
+
let cancelled = false;
|
|
521
|
+
if (control.updateAbort) {
|
|
522
|
+
control.updateAbort.abort();
|
|
523
|
+
cancelled = true;
|
|
524
|
+
store.log("warn", "收到取消请求,正在停止更新队列");
|
|
525
|
+
}
|
|
526
|
+
if (control.scanAbort) {
|
|
527
|
+
control.scanAbort.abort();
|
|
528
|
+
cancelled = true;
|
|
529
|
+
store.log("warn", "收到取消请求,正在停止扫描");
|
|
530
|
+
}
|
|
531
|
+
json(res, 200, { ok: true, cancelled });
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
register("/settings", async (req, res) => {
|
|
535
|
+
const body = await writeGate(req, res);
|
|
536
|
+
if (!body) return;
|
|
537
|
+
await store.load();
|
|
538
|
+
const patch = { ...(body.settings || {}) };
|
|
539
|
+
if (Object.prototype.hasOwnProperty.call(patch, "downloadDir")) {
|
|
540
|
+
const value = String(patch.downloadDir == null ? "" : patch.downloadDir).trim();
|
|
541
|
+
// 空 = 用默认目录;否则必须是绝对路径,避免把安装包写到意料之外的位置
|
|
542
|
+
if (value && !/^[A-Za-z]:[\\/]/.test(value) && !/^\\\\[^\\]/.test(value)) {
|
|
543
|
+
return json(res, 400, {
|
|
544
|
+
ok: false,
|
|
545
|
+
error: "downloadDir 必须是绝对路径(如 D:\\Apps)或 UNC 路径(\\\\server\\share)",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
patch.downloadDir = value;
|
|
549
|
+
}
|
|
550
|
+
const next = store.updateSettings(patch);
|
|
551
|
+
store.log("info", "设置已保存");
|
|
552
|
+
json(res, 200, { ok: true, settings: next, downloadDir: resolveDownloadDir(next) });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
register("/open-downloads", async (req, res) => {
|
|
556
|
+
const body = await writeGate(req, res);
|
|
557
|
+
if (!body) return;
|
|
558
|
+
await store.load();
|
|
559
|
+
const dir = resolveDownloadDir(store.state.settings);
|
|
560
|
+
const opened = process.platform === "win32" ? openFolder(dir) : false;
|
|
561
|
+
json(res, 200, { ok: opened, path: dir, exists: existsSync(dir) });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
register("/health.json", async (req, res) => {
|
|
565
|
+
await store.load();
|
|
566
|
+
const env = store.state.env || currentEnvironment();
|
|
567
|
+
const engine = store.state.engine && store.state.engine.id ? store.state.engine : defaultEngine(env);
|
|
568
|
+
json(res, 200, {
|
|
569
|
+
ok: true,
|
|
570
|
+
plugin: "dsh-app-updater",
|
|
571
|
+
version: PLUGIN_VERSION,
|
|
572
|
+
platform: env.platform,
|
|
573
|
+
env,
|
|
574
|
+
envSummary: describeEnvironment(env),
|
|
575
|
+
engine,
|
|
576
|
+
capabilities: capabilitiesFor(env, Boolean(engine.available)),
|
|
577
|
+
admission: admissionInfo(),
|
|
578
|
+
routes: routes.length,
|
|
579
|
+
dataDir: join(process.env.DSH_HOME || join(homedir(), ".dsh"), "dsh-app-updater"),
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
ctx.effect(
|
|
584
|
+
() => () => {
|
|
585
|
+
for (const dispose of routes) {
|
|
586
|
+
try {
|
|
587
|
+
dispose();
|
|
588
|
+
} catch {
|
|
589
|
+
/* 已释放 */
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
"dsh-app-updater: routes"
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
},
|
|
597
|
+
};
|