@iasiv5/dsh-skins 1.2.3 → 1.3.0
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/README.en.md +8 -20
- package/README.i18n.yaml +2 -2
- package/README.md +8 -20
- package/lib/client.js +44 -408
- package/lib/index.js +112 -891
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// src/index.js
|
|
2
|
-
import { readFileSync as readFileSync3 } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { join as join2 } from "node:path";
|
|
6
4
|
|
|
7
|
-
// src/host/
|
|
8
|
-
import {
|
|
5
|
+
// src/host/personalization/store.js
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
closeSync,
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync as mkdirSync2,
|
|
12
|
+
openSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
renameSync as renameSync2,
|
|
16
|
+
rmdirSync,
|
|
17
|
+
statfsSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync as writeFileSync2
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
9
23
|
|
|
10
24
|
// src/host/errors.js
|
|
11
25
|
function codedError(code, message, params) {
|
|
@@ -23,280 +37,6 @@ function publicError(error) {
|
|
|
23
37
|
return value;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
// src/host/restart.js
|
|
27
|
-
var RELAUNCH_HELPER = String.raw`
|
|
28
|
-
const { spawn } = require('node:child_process')
|
|
29
|
-
const [parentPid, executable, cwd, argsJson] = process.argv.slice(1)
|
|
30
|
-
const args = JSON.parse(argsJson)
|
|
31
|
-
const waitForParent = () => {
|
|
32
|
-
try {
|
|
33
|
-
process.kill(Number(parentPid), 0)
|
|
34
|
-
setTimeout(waitForParent, 100)
|
|
35
|
-
} catch {
|
|
36
|
-
setTimeout(() => {
|
|
37
|
-
const child = spawn(executable, args, {
|
|
38
|
-
cwd,
|
|
39
|
-
env: process.env,
|
|
40
|
-
detached: false,
|
|
41
|
-
stdio: 'ignore',
|
|
42
|
-
})
|
|
43
|
-
child.once('error', () => process.exit(1))
|
|
44
|
-
child.once('exit', code => process.exit(code == null ? 1 : code))
|
|
45
|
-
}, 750)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
waitForParent()
|
|
49
|
-
`;
|
|
50
|
-
var MANAGED_RESTART_EXIT_CODE = 75;
|
|
51
|
-
function serviceManagedEnv(env) {
|
|
52
|
-
return typeof env === "object" && env !== null && (env.INVOCATION_ID !== void 0 || env.NOTIFY_SOCKET !== void 0);
|
|
53
|
-
}
|
|
54
|
-
function createRestartScheduler(exit, overrides = {}) {
|
|
55
|
-
if (typeof exit !== "function") return { available: false, schedule() {
|
|
56
|
-
} };
|
|
57
|
-
const runtime = {
|
|
58
|
-
execPath: process.execPath,
|
|
59
|
-
argv: [...process.argv],
|
|
60
|
-
cwd: process.cwd(),
|
|
61
|
-
env: process.env,
|
|
62
|
-
pid: process.pid,
|
|
63
|
-
spawn,
|
|
64
|
-
setTimeout,
|
|
65
|
-
...overrides
|
|
66
|
-
};
|
|
67
|
-
let scheduled = false;
|
|
68
|
-
return {
|
|
69
|
-
available: true,
|
|
70
|
-
schedule() {
|
|
71
|
-
if (scheduled) return;
|
|
72
|
-
scheduled = true;
|
|
73
|
-
if (serviceManagedEnv(runtime.env)) {
|
|
74
|
-
const managed = runtime.setTimeout(() => exit(MANAGED_RESTART_EXIT_CODE), 150);
|
|
75
|
-
managed.unref?.();
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const helper = runtime.spawn(runtime.execPath, [
|
|
79
|
-
"-e",
|
|
80
|
-
RELAUNCH_HELPER,
|
|
81
|
-
String(runtime.pid),
|
|
82
|
-
runtime.execPath,
|
|
83
|
-
runtime.cwd,
|
|
84
|
-
JSON.stringify(runtime.argv.slice(1))
|
|
85
|
-
], {
|
|
86
|
-
cwd: runtime.cwd,
|
|
87
|
-
env: runtime.env,
|
|
88
|
-
detached: true,
|
|
89
|
-
stdio: "ignore"
|
|
90
|
-
});
|
|
91
|
-
helper.unref();
|
|
92
|
-
const timer = runtime.setTimeout(() => exit(0), 150);
|
|
93
|
-
timer.unref?.();
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
function agentList(agents) {
|
|
98
|
-
if (agents === null || agents === void 0 || typeof agents.list !== "function") return null;
|
|
99
|
-
try {
|
|
100
|
-
const list = agents.list();
|
|
101
|
-
return Array.isArray(list) ? list : null;
|
|
102
|
-
} catch {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
function restartSafety(agents) {
|
|
107
|
-
const list = agentList(agents);
|
|
108
|
-
if (list === null) return { state: "unknown", running: null };
|
|
109
|
-
const running = list.filter((agent) => agent?.status === "running").length;
|
|
110
|
-
return { state: running > 0 ? "blocked" : "safe", running };
|
|
111
|
-
}
|
|
112
|
-
async function waitForRestartSafety(agents, confirmUnknown = false) {
|
|
113
|
-
const list = agentList(agents);
|
|
114
|
-
if (list === null) {
|
|
115
|
-
if (!confirmUnknown) {
|
|
116
|
-
throw codedError("RESTART_SAFETY_UNKNOWN", "无法确认 Agent 运行状态,请再次确认后重启");
|
|
117
|
-
}
|
|
118
|
-
return { state: "unknown", running: null };
|
|
119
|
-
}
|
|
120
|
-
const running = list.filter((agent) => agent?.status === "running").length;
|
|
121
|
-
if (running > 0) {
|
|
122
|
-
throw codedError("AGENTS_RUNNING", `检测到 ${running} 个 Agent 正在运行,请等待任务结束后再重启`, { count: running });
|
|
123
|
-
}
|
|
124
|
-
await Promise.all(list.map((agent) => typeof agent?.whenIdle === "function" ? agent.whenIdle() : void 0));
|
|
125
|
-
const after = agentList(agents);
|
|
126
|
-
if (after === null) {
|
|
127
|
-
if (!confirmUnknown) {
|
|
128
|
-
throw codedError("RESTART_SAFETY_UNKNOWN", "复核 Agent 状态失败,请再次确认后重启");
|
|
129
|
-
}
|
|
130
|
-
return { state: "unknown", running: null };
|
|
131
|
-
}
|
|
132
|
-
const started = after.filter((agent) => agent?.status === "running").length;
|
|
133
|
-
if (started > 0) {
|
|
134
|
-
throw codedError("AGENTS_RUNNING", `检测到 ${started} 个 Agent 刚刚开始运行,请稍后再重启`, { count: started });
|
|
135
|
-
}
|
|
136
|
-
return { state: "safe", running: 0 };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// src/host/routes.js
|
|
140
|
-
function sendJson(response, status, value) {
|
|
141
|
-
response.writeHead(status, {
|
|
142
|
-
"content-type": "application/json; charset=utf-8",
|
|
143
|
-
"cache-control": "no-store"
|
|
144
|
-
});
|
|
145
|
-
response.end(JSON.stringify(value));
|
|
146
|
-
}
|
|
147
|
-
function header(headers, name2) {
|
|
148
|
-
const value = headers?.[name2];
|
|
149
|
-
return typeof value === "string" ? value : Array.isArray(value) ? value[0] : void 0;
|
|
150
|
-
}
|
|
151
|
-
function parseAuthority(authority) {
|
|
152
|
-
try {
|
|
153
|
-
return new URL(`http://${authority}`);
|
|
154
|
-
} catch {
|
|
155
|
-
return void 0;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
function canonicalAuthority(entry, parsed) {
|
|
159
|
-
const port = parsed.port !== "" ? parsed.port : new URL(`https://${entry}`).port;
|
|
160
|
-
return port === "" ? parsed.hostname : `${parsed.hostname}:${port}`;
|
|
161
|
-
}
|
|
162
|
-
function isLoopbackHostname(hostname) {
|
|
163
|
-
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
164
|
-
const parts = hostname.split(".");
|
|
165
|
-
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
166
|
-
}
|
|
167
|
-
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
168
|
-
return trustedHosts.some((entry) => {
|
|
169
|
-
const parsed = parseAuthority(entry);
|
|
170
|
-
if (parsed === void 0) return false;
|
|
171
|
-
return canonicalAuthority(entry, parsed) === parsed.hostname ? parsed.hostname === hostUrl.hostname : parsed.host === hostUrl.host;
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
function isTrustedRequest(request, trustedHosts = []) {
|
|
175
|
-
const host = header(request.headers, "host");
|
|
176
|
-
if (host === void 0) return false;
|
|
177
|
-
const hostUrl = parseAuthority(host);
|
|
178
|
-
if (hostUrl === void 0) return false;
|
|
179
|
-
const socketAddress = request.socket?.remoteAddress ?? request.info?.remoteAddress;
|
|
180
|
-
if (isLoopbackHostname(hostUrl.hostname)) {
|
|
181
|
-
if (typeof socketAddress === "string") {
|
|
182
|
-
const loopbackPeer = socketAddress === "127.0.0.1" || socketAddress === "::1" || socketAddress === "::ffff:127.0.0.1";
|
|
183
|
-
if (!loopbackPeer) return false;
|
|
184
|
-
}
|
|
185
|
-
} else if (!isTrustedAuthority(hostUrl, trustedHosts)) {
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
|
-
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
189
|
-
const origin = header(request.headers, "origin");
|
|
190
|
-
if (origin === void 0) return true;
|
|
191
|
-
try {
|
|
192
|
-
return new URL(origin).host === hostUrl.host;
|
|
193
|
-
} catch {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
async function readJsonBody(request, limit = 4096) {
|
|
198
|
-
const type = String(request.headers["content-type"] ?? "").toLowerCase();
|
|
199
|
-
if (!type.startsWith("application/json")) throw new Error("content-type must be application/json");
|
|
200
|
-
const chunks = [];
|
|
201
|
-
let size = 0;
|
|
202
|
-
for await (const chunk of request) {
|
|
203
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
204
|
-
size += buffer.length;
|
|
205
|
-
if (size > limit) throw new Error("request body too large");
|
|
206
|
-
chunks.push(buffer);
|
|
207
|
-
}
|
|
208
|
-
if (size === 0) return {};
|
|
209
|
-
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
210
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid request body");
|
|
211
|
-
return value;
|
|
212
|
-
}
|
|
213
|
-
function method(request, response, expected) {
|
|
214
|
-
if (request.method === expected) return true;
|
|
215
|
-
response.writeHead(405, { allow: expected });
|
|
216
|
-
response.end();
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
function mountUpdateRoutes(host, options) {
|
|
220
|
-
const { updater, restart, agents, trustedHosts = [] } = options;
|
|
221
|
-
const updateRoute = host.webServer.register({
|
|
222
|
-
kind: "exact",
|
|
223
|
-
path: "/dsh-skins/update",
|
|
224
|
-
handler: async (request, response) => {
|
|
225
|
-
if (!isTrustedRequest(request, trustedHosts)) return sendJson(response, 403, { error: "trusted DSH Web request required" });
|
|
226
|
-
if (request.method === "GET") {
|
|
227
|
-
const url = new URL(request.url ?? "/", "http://localhost");
|
|
228
|
-
const force = url.searchParams.get("force") === "1" && request.headers["x-dsh-skins-force"] === "1";
|
|
229
|
-
try {
|
|
230
|
-
const status = await updater.status(force);
|
|
231
|
-
return sendJson(response, 200, {
|
|
232
|
-
...status,
|
|
233
|
-
operation: updater.currentOperation(),
|
|
234
|
-
restartRequired: updater.restartRequired,
|
|
235
|
-
restartAvailable: restart.available === true,
|
|
236
|
-
restartSafety: restartSafety(agents)
|
|
237
|
-
});
|
|
238
|
-
} catch (error) {
|
|
239
|
-
return sendJson(response, 502, {
|
|
240
|
-
...publicError(error),
|
|
241
|
-
operation: updater.currentOperation(),
|
|
242
|
-
restartRequired: updater.restartRequired,
|
|
243
|
-
restartAvailable: restart.available === true,
|
|
244
|
-
restartSafety: restartSafety(agents)
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
if (!method(request, response, "POST")) return;
|
|
249
|
-
try {
|
|
250
|
-
return sendJson(response, 202, { operation: updater.startUpdate() });
|
|
251
|
-
} catch (error) {
|
|
252
|
-
return sendJson(response, 409, publicError(error));
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
const restartRoute = host.webServer.register({
|
|
257
|
-
kind: "exact",
|
|
258
|
-
path: "/dsh-skins/restart",
|
|
259
|
-
handler: async (request, response) => {
|
|
260
|
-
if (!isTrustedRequest(request, trustedHosts)) return sendJson(response, 403, { error: "trusted DSH Web request required" });
|
|
261
|
-
if (!method(request, response, "POST")) return;
|
|
262
|
-
if (restart.available !== true) {
|
|
263
|
-
return sendJson(response, 501, publicError(codedError("RESTART_UNAVAILABLE", "当前 DSH Host 不支持自重启")));
|
|
264
|
-
}
|
|
265
|
-
if (!updater.restartRequired) {
|
|
266
|
-
return sendJson(response, 409, publicError(codedError("NO_PENDING_UPDATE", "当前没有等待重启应用的更新")));
|
|
267
|
-
}
|
|
268
|
-
try {
|
|
269
|
-
const body = await readJsonBody(request);
|
|
270
|
-
const confirmUnknown = body.confirmUnknown === true;
|
|
271
|
-
await waitForRestartSafety(agents, confirmUnknown);
|
|
272
|
-
sendJson(response, 202, { restarting: true });
|
|
273
|
-
restart.schedule();
|
|
274
|
-
} catch (error) {
|
|
275
|
-
sendJson(response, 409, {
|
|
276
|
-
...publicError(error),
|
|
277
|
-
restartSafety: restartSafety(agents)
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
return () => {
|
|
283
|
-
restartRoute();
|
|
284
|
-
updateRoute();
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// src/host/self-update.js
|
|
289
|
-
import { randomUUID } from "node:crypto";
|
|
290
|
-
import {
|
|
291
|
-
existsSync,
|
|
292
|
-
mkdirSync as mkdirSync2,
|
|
293
|
-
readFileSync,
|
|
294
|
-
renameSync as renameSync2,
|
|
295
|
-
unlinkSync,
|
|
296
|
-
writeFileSync as writeFileSync2
|
|
297
|
-
} from "node:fs";
|
|
298
|
-
import { dirname as dirname2, join } from "node:path";
|
|
299
|
-
|
|
300
40
|
// src/host/atomic-write.js
|
|
301
41
|
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
302
42
|
import { dirname } from "node:path";
|
|
@@ -307,535 +47,6 @@ function atomicWriteText(file, content, fs = { mkdirSync, writeFileSync, renameS
|
|
|
307
47
|
fs.renameSync(temporary, file);
|
|
308
48
|
}
|
|
309
49
|
|
|
310
|
-
// src/host/self-update.js
|
|
311
|
-
var PACKAGE_NAME = "@iasiv5/dsh-skins";
|
|
312
|
-
var REPOSITORY = "iasiv5/dsh-skins";
|
|
313
|
-
var REGISTRY_LATEST_URL = "https://registry.npmjs.org/@iasiv5%2Fdsh-skins/latest";
|
|
314
|
-
var NPM_PACKAGE_URL = "https://www.npmjs.com/package/@iasiv5/dsh-skins";
|
|
315
|
-
var CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
316
|
-
var CACHE_SCHEMA_VERSION = 2;
|
|
317
|
-
var PROFILE_FILES = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"];
|
|
318
|
-
var STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
319
|
-
var NPM_SELF_SPEC_RE = /^npm:@iasiv5\/dsh-skins@/i;
|
|
320
|
-
function isRecord(value) {
|
|
321
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
322
|
-
}
|
|
323
|
-
function parseStableVersion(value) {
|
|
324
|
-
const raw = String(value ?? "").trim();
|
|
325
|
-
const version = raw.startsWith("v") ? raw.slice(1) : raw;
|
|
326
|
-
const match = STABLE_VERSION_RE.exec(version);
|
|
327
|
-
if (match === null) return null;
|
|
328
|
-
return {
|
|
329
|
-
version,
|
|
330
|
-
tag: `v${version}`,
|
|
331
|
-
parts: match.slice(1).map(Number)
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
function compareStableVersions(left, right) {
|
|
335
|
-
const a = parseStableVersion(left);
|
|
336
|
-
const b = parseStableVersion(right);
|
|
337
|
-
if (a === null || b === null) throw new Error("invalid stable semantic version");
|
|
338
|
-
for (let index = 0; index < 3; index += 1) {
|
|
339
|
-
if (a.parts[index] === b.parts[index]) continue;
|
|
340
|
-
return a.parts[index] > b.parts[index] ? 1 : -1;
|
|
341
|
-
}
|
|
342
|
-
return 0;
|
|
343
|
-
}
|
|
344
|
-
function repositoryIdentity(value) {
|
|
345
|
-
const raw = typeof value === "string" ? value : isRecord(value) && typeof value.url === "string" ? value.url : "";
|
|
346
|
-
return raw.trim().replace(/^git\+/, "").replace(/^git@github\.com:/i, "").replace(/^ssh:\/\/git@github\.com\//i, "").replace(/^https?:\/\/github\.com\//i, "").replace(/\.git$/i, "").replace(/\/$/, "").toLowerCase();
|
|
347
|
-
}
|
|
348
|
-
function detectInstallSource(spec) {
|
|
349
|
-
const value = String(spec ?? "").trim();
|
|
350
|
-
if (/^link:/i.test(value)) return { kind: "link", spec: value };
|
|
351
|
-
if (/^file:/i.test(value) || /\.tgz(?:$|[?#])/i.test(value)) return { kind: "file", spec: value };
|
|
352
|
-
if (/^(?:git\+|github:|git@|ssh:\/\/|https?:\/\/)/i.test(value)) return { kind: "github", spec: value };
|
|
353
|
-
const range = NPM_SELF_SPEC_RE.test(value) ? value.replace(NPM_SELF_SPEC_RE, "") : value;
|
|
354
|
-
if (range === "*" || range === "latest" || /^[\s~^><=]*\d/.test(range)) {
|
|
355
|
-
return { kind: "npm", spec: value };
|
|
356
|
-
}
|
|
357
|
-
return { kind: "unknown", spec: value };
|
|
358
|
-
}
|
|
359
|
-
function readJson(file, fallback = null) {
|
|
360
|
-
try {
|
|
361
|
-
return JSON.parse(readFileSync(file, "utf8"));
|
|
362
|
-
} catch {
|
|
363
|
-
return fallback;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
function readProfileManifest(profileDir) {
|
|
367
|
-
return readJson(join(profileDir, "package.json"), {});
|
|
368
|
-
}
|
|
369
|
-
function readInstalledManifest(profileDir) {
|
|
370
|
-
return readJson(join(profileDir, "node_modules", PACKAGE_NAME, "package.json"), null);
|
|
371
|
-
}
|
|
372
|
-
function captureProfileSnapshot(profileDir) {
|
|
373
|
-
return PROFILE_FILES.map((name2) => {
|
|
374
|
-
const file = join(profileDir, name2);
|
|
375
|
-
return {
|
|
376
|
-
name: name2,
|
|
377
|
-
exists: existsSync(file),
|
|
378
|
-
content: existsSync(file) ? readFileSync(file) : null
|
|
379
|
-
};
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
function restoreProfileSnapshot(profileDir, snapshot) {
|
|
383
|
-
for (const item of snapshot) {
|
|
384
|
-
const file = join(profileDir, item.name);
|
|
385
|
-
if (item.exists) {
|
|
386
|
-
mkdirSync2(dirname2(file), { recursive: true });
|
|
387
|
-
const temporary = `${file}.${process.pid}.rollback`;
|
|
388
|
-
writeFileSync2(temporary, item.content);
|
|
389
|
-
renameSync2(temporary, file);
|
|
390
|
-
} else if (existsSync(file)) {
|
|
391
|
-
unlinkSync(file);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
function preserveProfileBundles(profileDir, snapshot) {
|
|
396
|
-
const packageSnapshot = snapshot.find((item) => item.name === "package.json" && item.exists);
|
|
397
|
-
if (packageSnapshot?.content === null || packageSnapshot?.content === void 0) return;
|
|
398
|
-
let before;
|
|
399
|
-
try {
|
|
400
|
-
before = JSON.parse(packageSnapshot.content.toString("utf8"));
|
|
401
|
-
} catch {
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
const bundles = before.dsh?.profile?.bundles;
|
|
405
|
-
if (!Array.isArray(bundles) || !bundles.includes(PACKAGE_NAME)) return;
|
|
406
|
-
const current = readProfileManifest(profileDir);
|
|
407
|
-
current.dsh = {
|
|
408
|
-
...current.dsh,
|
|
409
|
-
profile: {
|
|
410
|
-
...current.dsh?.profile,
|
|
411
|
-
bundles: [...bundles]
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
|
-
atomicWriteText(join(profileDir, "package.json"), `${JSON.stringify(current, null, 2)}
|
|
415
|
-
`);
|
|
416
|
-
}
|
|
417
|
-
function validatePluginManifest(manifest, expectedVersion) {
|
|
418
|
-
if (!isRecord(manifest)) throw codedError("RELEASE_MANIFEST_MISSING", "发布的包缺少 package.json");
|
|
419
|
-
if (manifest.name !== PACKAGE_NAME) {
|
|
420
|
-
throw codedError("RELEASE_NAME_MISMATCH", `发布的包名必须是 ${PACKAGE_NAME}`, { expected: PACKAGE_NAME });
|
|
421
|
-
}
|
|
422
|
-
if (manifest.version !== expectedVersion) {
|
|
423
|
-
throw codedError(
|
|
424
|
-
"RELEASE_VERSION_MISMATCH",
|
|
425
|
-
`发布版本 v${expectedVersion} 与包版本 ${String(manifest.version ?? "missing")} 不一致`,
|
|
426
|
-
{ tag: expectedVersion, version: String(manifest.version ?? "missing") }
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
if (repositoryIdentity(manifest.repository) !== REPOSITORY.toLowerCase()) {
|
|
430
|
-
throw codedError("RELEASE_REPOSITORY_MISMATCH", `发布的包仓库必须是 ${REPOSITORY}`, { repository: REPOSITORY });
|
|
431
|
-
}
|
|
432
|
-
if (!isRecord(manifest.dsh) || !isRecord(manifest.dsh.client) || manifest.dsh.client.platform !== "web") {
|
|
433
|
-
throw codedError("RELEASE_NOT_WEB_PLUGIN", "发布的包不是 DSH Web 客户端插件");
|
|
434
|
-
}
|
|
435
|
-
if (!isRecord(manifest.dsh.bundle) || typeof manifest.dsh.bundle.patch !== "string") {
|
|
436
|
-
throw codedError("RELEASE_NO_BUNDLE_PATCH", "发布的包未声明 DSH bundle patch");
|
|
437
|
-
}
|
|
438
|
-
return manifest;
|
|
439
|
-
}
|
|
440
|
-
async function fetchRegistryJson(url, options = {}) {
|
|
441
|
-
const response = await fetch(url, {
|
|
442
|
-
headers: {
|
|
443
|
-
accept: "application/json",
|
|
444
|
-
"user-agent": `${PACKAGE_NAME}/${options.currentVersion ?? "unknown"}`
|
|
445
|
-
},
|
|
446
|
-
signal: options.signal
|
|
447
|
-
});
|
|
448
|
-
if (!response.ok) {
|
|
449
|
-
throw codedError(
|
|
450
|
-
"REGISTRY_CHECK_FAILED",
|
|
451
|
-
`npm registry 更新检查失败(HTTP ${response.status})`,
|
|
452
|
-
{ status: response.status }
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
return response.json();
|
|
456
|
-
}
|
|
457
|
-
async function fetchLatestStableRelease(options = {}) {
|
|
458
|
-
const manifest = await (options.fetchJson ?? fetchRegistryJson)(REGISTRY_LATEST_URL, options);
|
|
459
|
-
if (!isRecord(manifest) || manifest.name !== PACKAGE_NAME) {
|
|
460
|
-
throw codedError("REGISTRY_NAME_MISMATCH", "npm registry latest 返回的包名异常", { expected: PACKAGE_NAME });
|
|
461
|
-
}
|
|
462
|
-
const parsed = parseStableVersion(manifest.version);
|
|
463
|
-
if (parsed === null || manifest.version !== parsed.version) {
|
|
464
|
-
throw codedError(
|
|
465
|
-
"REGISTRY_VERSION_INVALID",
|
|
466
|
-
`npm registry latest 不是严格 X.Y.Z 版本:${String(manifest.version ?? "missing")}`,
|
|
467
|
-
{ version: String(manifest.version ?? "missing") }
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
return {
|
|
471
|
-
version: parsed.version,
|
|
472
|
-
tag: parsed.tag,
|
|
473
|
-
htmlUrl: `${NPM_PACKAGE_URL}/v/${parsed.version}`,
|
|
474
|
-
name: parsed.tag
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
async function resolveReleaseArtifact(release, options = {}) {
|
|
478
|
-
const fetchJson = options.fetchJson ?? fetchRegistryJson;
|
|
479
|
-
const manifest = await fetchJson(
|
|
480
|
-
`https://registry.npmjs.org/@iasiv5%2Fdsh-skins/${release.version}`,
|
|
481
|
-
options
|
|
482
|
-
);
|
|
483
|
-
if (!isRecord(manifest) || !isRecord(manifest.dist) || typeof manifest.dist.integrity !== "string") {
|
|
484
|
-
throw codedError("REGISTRY_INTEGRITY_MISSING", "npm registry 版本元数据缺少 dist.integrity");
|
|
485
|
-
}
|
|
486
|
-
validatePluginManifest(manifest, release.version);
|
|
487
|
-
return { ...release, manifest, integrity: manifest.dist.integrity };
|
|
488
|
-
}
|
|
489
|
-
function cachePayload(currentVersion, checkedAt, release) {
|
|
490
|
-
return {
|
|
491
|
-
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
492
|
-
currentVersion,
|
|
493
|
-
checkedAt,
|
|
494
|
-
release
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
function validCachedRelease(value, currentVersion, now, ttlMs) {
|
|
498
|
-
if (!isRecord(value) || value.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
|
|
499
|
-
if (value.currentVersion !== currentVersion) return null;
|
|
500
|
-
if (!Number.isFinite(value.checkedAt) || now - value.checkedAt < 0 || now - value.checkedAt >= ttlMs) return null;
|
|
501
|
-
const release = value.release;
|
|
502
|
-
if (!isRecord(release) || parseStableVersion(release.version) === null) return null;
|
|
503
|
-
if (release.tag !== `v${release.version}` || typeof release.htmlUrl !== "string") return null;
|
|
504
|
-
return { checkedAt: value.checkedAt, release };
|
|
505
|
-
}
|
|
506
|
-
function publicOperation(operation) {
|
|
507
|
-
if (operation === null) return null;
|
|
508
|
-
return {
|
|
509
|
-
id: operation.id,
|
|
510
|
-
phase: operation.phase,
|
|
511
|
-
message: operation.message,
|
|
512
|
-
startedAt: operation.startedAt,
|
|
513
|
-
...operation.finishedAt === void 0 ? {} : { finishedAt: operation.finishedAt },
|
|
514
|
-
...operation.error === void 0 ? {} : { error: operation.error },
|
|
515
|
-
...operation.rolledBack === void 0 ? {} : { rolledBack: operation.rolledBack },
|
|
516
|
-
...operation.release === void 0 ? {} : { release: operation.release },
|
|
517
|
-
...operation.code === void 0 ? {} : { code: operation.code },
|
|
518
|
-
...operation.params === void 0 ? {} : { params: operation.params },
|
|
519
|
-
...operation.rollbackError === void 0 ? {} : { rollbackError: operation.rollbackError }
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
function sourceReason(source) {
|
|
523
|
-
if (source.kind === "link") return "development-link";
|
|
524
|
-
if (source.kind === "file") return "local-file";
|
|
525
|
-
if (source.kind === "github") return "legacy-github";
|
|
526
|
-
if (source.kind !== "npm") return "unsupported-source";
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
529
|
-
function validateInstalledState(profileDir, artifact) {
|
|
530
|
-
const profile = readProfileManifest(profileDir);
|
|
531
|
-
const dependency = profile.dependencies?.[PACKAGE_NAME];
|
|
532
|
-
if (typeof dependency !== "string" || dependency !== artifact.version) {
|
|
533
|
-
throw codedError("PROFILE_NOT_PINNED", "profile 未固定到已验证的精确版本");
|
|
534
|
-
}
|
|
535
|
-
if (!Array.isArray(profile.dsh?.profile?.bundles) || !profile.dsh.profile.bundles.includes(PACKAGE_NAME)) {
|
|
536
|
-
throw codedError("PROFILE_BUNDLE_MISSING", "profile 未注册 dsh-skins bundle");
|
|
537
|
-
}
|
|
538
|
-
const installed = readInstalledManifest(profileDir);
|
|
539
|
-
validatePluginManifest(installed, artifact.version);
|
|
540
|
-
return installed;
|
|
541
|
-
}
|
|
542
|
-
function createSelfUpdater(options, dependencies = {}) {
|
|
543
|
-
const profileDir = options.profileDir;
|
|
544
|
-
const cacheFile = options.cacheFile;
|
|
545
|
-
const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
|
|
546
|
-
const now = dependencies.now ?? Date.now;
|
|
547
|
-
const latestRelease = dependencies.fetchLatestRelease ?? fetchLatestStableRelease;
|
|
548
|
-
const resolveArtifact = dependencies.resolveReleaseArtifact ?? resolveReleaseArtifact;
|
|
549
|
-
const runner = dependencies.runner;
|
|
550
|
-
if (typeof runner !== "function") throw new Error("self updater requires a profile runner");
|
|
551
|
-
let currentVersion = options.currentVersion;
|
|
552
|
-
if (parseStableVersion(currentVersion) === null) throw new Error(`invalid current version ${currentVersion}`);
|
|
553
|
-
let memoryCache = null;
|
|
554
|
-
let operation = null;
|
|
555
|
-
let updatePromise = null;
|
|
556
|
-
let restartRequired = false;
|
|
557
|
-
const readSource = () => {
|
|
558
|
-
const profile = readProfileManifest(profileDir);
|
|
559
|
-
return detectInstallSource(profile.dependencies?.[PACKAGE_NAME]);
|
|
560
|
-
};
|
|
561
|
-
const readCache = () => {
|
|
562
|
-
if (memoryCache !== null) {
|
|
563
|
-
const valid2 = validCachedRelease(memoryCache, currentVersion, now(), ttlMs);
|
|
564
|
-
if (valid2 !== null) return valid2;
|
|
565
|
-
}
|
|
566
|
-
const disk = readJson(cacheFile, null);
|
|
567
|
-
const valid = validCachedRelease(disk, currentVersion, now(), ttlMs);
|
|
568
|
-
if (valid !== null) memoryCache = disk;
|
|
569
|
-
return valid;
|
|
570
|
-
};
|
|
571
|
-
const writeCache = (checkedAt, release) => {
|
|
572
|
-
memoryCache = cachePayload(currentVersion, checkedAt, release);
|
|
573
|
-
atomicWriteText(cacheFile, `${JSON.stringify(memoryCache, null, 2)}
|
|
574
|
-
`);
|
|
575
|
-
};
|
|
576
|
-
const buildStatus = (release, checkedAt, cached) => {
|
|
577
|
-
const source = readSource();
|
|
578
|
-
const releaseNewer = compareStableVersions(release.version, currentVersion) > 0;
|
|
579
|
-
const disabledReason = sourceReason(source);
|
|
580
|
-
return {
|
|
581
|
-
currentVersion,
|
|
582
|
-
source,
|
|
583
|
-
latest: release,
|
|
584
|
-
checkedAt,
|
|
585
|
-
cached,
|
|
586
|
-
updateAvailable: releaseNewer,
|
|
587
|
-
canUpdate: releaseNewer && disabledReason === null,
|
|
588
|
-
disabledReason,
|
|
589
|
-
restartRequired
|
|
590
|
-
};
|
|
591
|
-
};
|
|
592
|
-
const status = async (force = false) => {
|
|
593
|
-
if (!force) {
|
|
594
|
-
const cached = readCache();
|
|
595
|
-
if (cached !== null) return buildStatus(cached.release, cached.checkedAt, true);
|
|
596
|
-
}
|
|
597
|
-
const signal = AbortSignal.timeout(1e4);
|
|
598
|
-
const release = await latestRelease({ currentVersion, signal });
|
|
599
|
-
const checkedAt = now();
|
|
600
|
-
writeCache(checkedAt, release);
|
|
601
|
-
return buildStatus(release, checkedAt, false);
|
|
602
|
-
};
|
|
603
|
-
const setOperation = (patch) => {
|
|
604
|
-
Object.assign(operation, patch);
|
|
605
|
-
if (operation.phase === "done" || operation.phase === "failed") {
|
|
606
|
-
operation.finishedAt = new Date(now()).toISOString();
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
const executeUpdate = async () => {
|
|
610
|
-
let snapshot = null;
|
|
611
|
-
let previousSpec = null;
|
|
612
|
-
let previousVersion = null;
|
|
613
|
-
try {
|
|
614
|
-
setOperation({ phase: "checking", message: "正在重新检查最新正式版本" });
|
|
615
|
-
const before = await status(true);
|
|
616
|
-
if (!before.canUpdate) {
|
|
617
|
-
if (before.disabledReason === "development-link") {
|
|
618
|
-
throw codedError("UPDATE_LINK_PROTECTED", "本地 link 开发模式不会被在线更新覆盖");
|
|
619
|
-
}
|
|
620
|
-
if (before.updateAvailable) {
|
|
621
|
-
throw codedError("UPDATE_SOURCE_UNSUPPORTED", "当前安装来源不支持一键更新");
|
|
622
|
-
}
|
|
623
|
-
throw codedError("UPDATE_ALREADY_LATEST", "已经是最新正式版本");
|
|
624
|
-
}
|
|
625
|
-
setOperation({ phase: "preparing", message: "正在验证 npm 版本元数据" });
|
|
626
|
-
const artifact = await resolveArtifact(before.latest, {
|
|
627
|
-
currentVersion,
|
|
628
|
-
signal: AbortSignal.timeout(15e3)
|
|
629
|
-
});
|
|
630
|
-
operation.release = {
|
|
631
|
-
version: artifact.version,
|
|
632
|
-
tag: artifact.tag,
|
|
633
|
-
htmlUrl: artifact.htmlUrl
|
|
634
|
-
};
|
|
635
|
-
const profile = readProfileManifest(profileDir);
|
|
636
|
-
previousSpec = profile.dependencies?.[PACKAGE_NAME];
|
|
637
|
-
const previousManifest = readInstalledManifest(profileDir);
|
|
638
|
-
previousVersion = previousManifest?.version ?? currentVersion;
|
|
639
|
-
if (detectInstallSource(previousSpec).kind !== "npm") {
|
|
640
|
-
throw codedError("UPDATE_SOURCE_CHANGED", "更新开始前安装来源已变化,请重新打开主题切换器");
|
|
641
|
-
}
|
|
642
|
-
validatePluginManifest(previousManifest, previousVersion);
|
|
643
|
-
snapshot = captureProfileSnapshot(profileDir);
|
|
644
|
-
setOperation({ phase: "installing", message: `正在安装 ${artifact.tag}` });
|
|
645
|
-
await runner("web", ["add", `${PACKAGE_NAME}@${artifact.version}`], {
|
|
646
|
-
onChunk: (chunk) => {
|
|
647
|
-
if (String(chunk).trim() !== "") operation.message = `正在安装 ${artifact.tag}`;
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
preserveProfileBundles(profileDir, snapshot);
|
|
651
|
-
setOperation({ phase: "validating", message: "正在校验安装结果" });
|
|
652
|
-
validateInstalledState(profileDir, artifact);
|
|
653
|
-
currentVersion = artifact.version;
|
|
654
|
-
restartRequired = true;
|
|
655
|
-
writeCache(now(), {
|
|
656
|
-
version: artifact.version,
|
|
657
|
-
tag: artifact.tag,
|
|
658
|
-
htmlUrl: artifact.htmlUrl,
|
|
659
|
-
name: artifact.name
|
|
660
|
-
});
|
|
661
|
-
setOperation({ phase: "done", message: `已安装 ${artifact.tag},重启后生效`, rolledBack: false });
|
|
662
|
-
} catch (error) {
|
|
663
|
-
const original = error instanceof Error ? error : new Error(String(error));
|
|
664
|
-
let rollbackError = null;
|
|
665
|
-
let rolledBack = false;
|
|
666
|
-
if (snapshot !== null && typeof previousSpec === "string" && parseStableVersion(previousVersion) !== null) {
|
|
667
|
-
setOperation({ phase: "rollback", message: "更新失败,正在恢复原版本" });
|
|
668
|
-
try {
|
|
669
|
-
await runner("web", ["add", `${PACKAGE_NAME}@${previousVersion}`], {});
|
|
670
|
-
restoreProfileSnapshot(profileDir, snapshot);
|
|
671
|
-
const restored = readInstalledManifest(profileDir);
|
|
672
|
-
validatePluginManifest(restored, previousVersion);
|
|
673
|
-
const restoredProfile = readProfileManifest(profileDir);
|
|
674
|
-
if (restoredProfile.dependencies?.[PACKAGE_NAME] !== previousSpec) {
|
|
675
|
-
throw codedError("ROLLBACK_LOCKFILE_MISMATCH", "恢复后的版本锁定校验失败");
|
|
676
|
-
}
|
|
677
|
-
if (!Array.isArray(restoredProfile.dsh?.profile?.bundles) || !restoredProfile.dsh.profile.bundles.includes(PACKAGE_NAME)) {
|
|
678
|
-
throw codedError("ROLLBACK_BUNDLE_MISSING", "恢复后的 bundle 注册校验失败");
|
|
679
|
-
}
|
|
680
|
-
rolledBack = true;
|
|
681
|
-
} catch (rollbackFailure) {
|
|
682
|
-
rollbackError = rollbackFailure instanceof Error ? rollbackFailure : new Error(String(rollbackFailure));
|
|
683
|
-
try {
|
|
684
|
-
restoreProfileSnapshot(profileDir, snapshot);
|
|
685
|
-
} catch {
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
const message = rollbackError === null ? original.message : `${original.message};自动回滚失败:${rollbackError.message}`;
|
|
690
|
-
setOperation({
|
|
691
|
-
phase: "failed",
|
|
692
|
-
message,
|
|
693
|
-
error: message,
|
|
694
|
-
rolledBack,
|
|
695
|
-
...original.code === void 0 ? {} : { code: original.code },
|
|
696
|
-
...original.params === void 0 ? {} : { params: original.params },
|
|
697
|
-
...rollbackError === null ? {} : {
|
|
698
|
-
rollbackError: {
|
|
699
|
-
message: rollbackError.message,
|
|
700
|
-
...rollbackError.code === void 0 ? {} : { code: rollbackError.code },
|
|
701
|
-
...rollbackError.params === void 0 ? {} : { params: rollbackError.params }
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
});
|
|
705
|
-
} finally {
|
|
706
|
-
updatePromise = null;
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
return {
|
|
710
|
-
async status(force = false) {
|
|
711
|
-
return status(force);
|
|
712
|
-
},
|
|
713
|
-
startUpdate() {
|
|
714
|
-
if (updatePromise !== null) return publicOperation(operation);
|
|
715
|
-
operation = {
|
|
716
|
-
id: randomUUID(),
|
|
717
|
-
phase: "queued",
|
|
718
|
-
message: "更新已排队",
|
|
719
|
-
startedAt: new Date(now()).toISOString()
|
|
720
|
-
};
|
|
721
|
-
updatePromise = executeUpdate();
|
|
722
|
-
return publicOperation(operation);
|
|
723
|
-
},
|
|
724
|
-
currentOperation() {
|
|
725
|
-
return publicOperation(operation);
|
|
726
|
-
},
|
|
727
|
-
get restartRequired() {
|
|
728
|
-
return restartRequired;
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// src/host/runner.js
|
|
734
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
735
|
-
import { dirname as dirname3, resolve } from "node:path";
|
|
736
|
-
var COMMAND_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
737
|
-
var WINDOWS_METACHARS = /[\s"&|<>^()%!]/;
|
|
738
|
-
function quoteWindowsArg(value) {
|
|
739
|
-
if (!WINDOWS_METACHARS.test(value)) return value;
|
|
740
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
741
|
-
}
|
|
742
|
-
function spawnPortable(file, args, options) {
|
|
743
|
-
const { viaShell = false, ...spawnOptions } = options;
|
|
744
|
-
if (process.platform !== "win32" || viaShell !== true) {
|
|
745
|
-
return spawn2(file, args, { ...spawnOptions, shell: false });
|
|
746
|
-
}
|
|
747
|
-
const commandLine = [file, ...args].map(quoteWindowsArg).join(" ");
|
|
748
|
-
return spawn2(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${commandLine}"`], {
|
|
749
|
-
...spawnOptions,
|
|
750
|
-
shell: false,
|
|
751
|
-
windowsVerbatimArguments: true
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
function currentDshInvocation() {
|
|
755
|
-
const entry = process.argv[1];
|
|
756
|
-
if (entry !== void 0 && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
757
|
-
const absolute = resolve(entry);
|
|
758
|
-
return {
|
|
759
|
-
file: process.execPath,
|
|
760
|
-
prefix: [...process.execArgv, absolute],
|
|
761
|
-
cwd: dirname3(absolute),
|
|
762
|
-
viaShell: false
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
return { file: "dsh", prefix: [], cwd: void 0, viaShell: process.platform === "win32" };
|
|
766
|
-
}
|
|
767
|
-
function runDshPlugin(profile, args, options = {}) {
|
|
768
|
-
const invocation = options.invocation ?? currentDshInvocation();
|
|
769
|
-
const argv = [...invocation.prefix, "plugin", "--profile", profile, ...args, "--reporter=ndjson"];
|
|
770
|
-
return new Promise((resolvePromise, reject) => {
|
|
771
|
-
const child = spawnPortable(invocation.file, argv, {
|
|
772
|
-
cwd: invocation.cwd,
|
|
773
|
-
env: { ...process.env, CI: "true" },
|
|
774
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
775
|
-
detached: process.platform !== "win32",
|
|
776
|
-
viaShell: invocation.viaShell
|
|
777
|
-
});
|
|
778
|
-
let output = "";
|
|
779
|
-
let settled = false;
|
|
780
|
-
const finish = (error) => {
|
|
781
|
-
if (settled) return;
|
|
782
|
-
settled = true;
|
|
783
|
-
clearTimeout(timer);
|
|
784
|
-
if (error) reject(error);
|
|
785
|
-
else resolvePromise(output);
|
|
786
|
-
};
|
|
787
|
-
const append = (chunk) => {
|
|
788
|
-
const text = String(chunk);
|
|
789
|
-
output = (output + text).slice(-256 * 1024);
|
|
790
|
-
options.onChunk?.(text);
|
|
791
|
-
};
|
|
792
|
-
child.stdout?.on("data", append);
|
|
793
|
-
child.stderr?.on("data", append);
|
|
794
|
-
child.on("error", finish);
|
|
795
|
-
child.on("close", (code) => {
|
|
796
|
-
if (code === 0) finish();
|
|
797
|
-
else {
|
|
798
|
-
const tail = output.trim().slice(-1200) || "no output";
|
|
799
|
-
finish(codedError(
|
|
800
|
-
"UPDATE_COMMAND_FAILED",
|
|
801
|
-
`DSH 插件更新失败(exit ${String(code)}):${tail}`,
|
|
802
|
-
{ exitCode: String(code), output: tail }
|
|
803
|
-
));
|
|
804
|
-
}
|
|
805
|
-
});
|
|
806
|
-
const timer = setTimeout(() => {
|
|
807
|
-
if (child.pid !== void 0) {
|
|
808
|
-
try {
|
|
809
|
-
if (process.platform === "win32") child.kill("SIGTERM");
|
|
810
|
-
else process.kill(-child.pid, "SIGTERM");
|
|
811
|
-
} catch {
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
finish(codedError("UPDATE_COMMAND_TIMEOUT", "DSH 插件更新超时"));
|
|
815
|
-
}, options.timeoutMs ?? COMMAND_TIMEOUT_MS);
|
|
816
|
-
timer.unref?.();
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// src/host/personalization/store.js
|
|
821
|
-
import { createHash, randomUUID as randomUUID2 } from "node:crypto";
|
|
822
|
-
import {
|
|
823
|
-
closeSync,
|
|
824
|
-
copyFileSync,
|
|
825
|
-
existsSync as existsSync2,
|
|
826
|
-
mkdirSync as mkdirSync3,
|
|
827
|
-
openSync,
|
|
828
|
-
readFileSync as readFileSync2,
|
|
829
|
-
readdirSync,
|
|
830
|
-
renameSync as renameSync3,
|
|
831
|
-
rmdirSync,
|
|
832
|
-
statfsSync,
|
|
833
|
-
statSync,
|
|
834
|
-
unlinkSync as unlinkSync2,
|
|
835
|
-
writeFileSync as writeFileSync3
|
|
836
|
-
} from "node:fs";
|
|
837
|
-
import { join as join2 } from "node:path";
|
|
838
|
-
|
|
839
50
|
// src/host/personalization/image-meta.js
|
|
840
51
|
function ascii(buffer, offset, length) {
|
|
841
52
|
return String.fromCharCode(...buffer.subarray(offset, offset + length));
|
|
@@ -1242,12 +453,12 @@ var CORRUPT_BACKUP_LIMIT = 3;
|
|
|
1242
453
|
var ID_ATTEMPTS = 5;
|
|
1243
454
|
function defaultFs() {
|
|
1244
455
|
return {
|
|
1245
|
-
existsSync
|
|
1246
|
-
mkdirSync:
|
|
1247
|
-
readFileSync
|
|
1248
|
-
writeFileSync:
|
|
1249
|
-
renameSync:
|
|
1250
|
-
unlinkSync
|
|
456
|
+
existsSync,
|
|
457
|
+
mkdirSync: mkdirSync2,
|
|
458
|
+
readFileSync,
|
|
459
|
+
writeFileSync: writeFileSync2,
|
|
460
|
+
renameSync: renameSync2,
|
|
461
|
+
unlinkSync,
|
|
1251
462
|
readdirSync,
|
|
1252
463
|
rmdirSync,
|
|
1253
464
|
statSync,
|
|
@@ -1315,9 +526,9 @@ function createPersonalizationStore(options = {}) {
|
|
|
1315
526
|
}
|
|
1316
527
|
const fs = options.fs ?? defaultFs();
|
|
1317
528
|
const now = options.now ?? Date.now;
|
|
1318
|
-
const stateFile =
|
|
1319
|
-
const assetsDir =
|
|
1320
|
-
const quarantineDir =
|
|
529
|
+
const stateFile = join(dataDir, STATE_FILE);
|
|
530
|
+
const assetsDir = join(dataDir, ASSETS_DIR);
|
|
531
|
+
const quarantineDir = join(dataDir, QUARANTINE_DIR);
|
|
1321
532
|
let state = null;
|
|
1322
533
|
let mode = "normal";
|
|
1323
534
|
let recovery = null;
|
|
@@ -1345,7 +556,7 @@ function createPersonalizationStore(options = {}) {
|
|
|
1345
556
|
return fs.readdirSync(assetsDir).filter((name2) => !name2.startsWith("."));
|
|
1346
557
|
}
|
|
1347
558
|
function blobFileFor(id, extension) {
|
|
1348
|
-
return
|
|
559
|
+
return join(assetsDir, `${id}.${extension}`);
|
|
1349
560
|
}
|
|
1350
561
|
function commitState(next) {
|
|
1351
562
|
atomicWriteText(stateFile, `${JSON.stringify(next, null, 2)}
|
|
@@ -1358,7 +569,7 @@ function createPersonalizationStore(options = {}) {
|
|
|
1358
569
|
return run;
|
|
1359
570
|
}
|
|
1360
571
|
function sniffBlobFile(name2) {
|
|
1361
|
-
const file =
|
|
572
|
+
const file = join(assetsDir, name2);
|
|
1362
573
|
let buffer;
|
|
1363
574
|
try {
|
|
1364
575
|
buffer = fs.readFileSync(file);
|
|
@@ -1472,7 +683,7 @@ function createPersonalizationStore(options = {}) {
|
|
|
1472
683
|
const id = name2.split(".")[0];
|
|
1473
684
|
if (!live.has(id)) {
|
|
1474
685
|
try {
|
|
1475
|
-
fs.unlinkSync(
|
|
686
|
+
fs.unlinkSync(join(assetsDir, name2));
|
|
1476
687
|
} catch {
|
|
1477
688
|
}
|
|
1478
689
|
}
|
|
@@ -1593,7 +804,7 @@ function createPersonalizationStore(options = {}) {
|
|
|
1593
804
|
}
|
|
1594
805
|
function writeBlobExclusive(buffer, extension) {
|
|
1595
806
|
for (let attempt = 0; attempt < ID_ATTEMPTS; attempt += 1) {
|
|
1596
|
-
const id = ASSET_ID_PREFIX +
|
|
807
|
+
const id = ASSET_ID_PREFIX + randomUUID().replaceAll("-", "");
|
|
1597
808
|
const target = blobFileFor(id, extension);
|
|
1598
809
|
let descriptor;
|
|
1599
810
|
try {
|
|
@@ -1729,7 +940,7 @@ function createPersonalizationStore(options = {}) {
|
|
|
1729
940
|
fs.mkdirSync(quarantineDir, { recursive: true });
|
|
1730
941
|
for (const name2 of pending.quarantine) {
|
|
1731
942
|
try {
|
|
1732
|
-
fs.renameSync(
|
|
943
|
+
fs.renameSync(join(assetsDir, name2), join(quarantineDir, name2));
|
|
1733
944
|
} catch {
|
|
1734
945
|
}
|
|
1735
946
|
}
|
|
@@ -1738,12 +949,12 @@ function createPersonalizationStore(options = {}) {
|
|
|
1738
949
|
commitState(cleaned);
|
|
1739
950
|
}
|
|
1740
951
|
function pruneCorruptBackups() {
|
|
1741
|
-
const dir =
|
|
952
|
+
const dir = join(dataDir);
|
|
1742
953
|
if (!fs.existsSync(dir)) return;
|
|
1743
954
|
const backups = fs.readdirSync(dir).filter((name2) => name2.startsWith(`${STATE_FILE}.corrupt.`)).sort();
|
|
1744
955
|
while (backups.length > CORRUPT_BACKUP_LIMIT) {
|
|
1745
956
|
try {
|
|
1746
|
-
fs.unlinkSync(
|
|
957
|
+
fs.unlinkSync(join(dir, backups.shift()));
|
|
1747
958
|
} catch {
|
|
1748
959
|
}
|
|
1749
960
|
}
|
|
@@ -1764,6 +975,52 @@ function createPersonalizationStore(options = {}) {
|
|
|
1764
975
|
}
|
|
1765
976
|
|
|
1766
977
|
// src/host/personalization-routes.js
|
|
978
|
+
function parseAuthority(authority) {
|
|
979
|
+
try {
|
|
980
|
+
return new URL(`http://${authority}`);
|
|
981
|
+
} catch {
|
|
982
|
+
return void 0;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function canonicalAuthority(entry, parsed) {
|
|
986
|
+
const port = parsed.port !== "" ? parsed.port : new URL(`https://${entry}`).port;
|
|
987
|
+
return port === "" ? parsed.hostname : `${parsed.hostname}:${port}`;
|
|
988
|
+
}
|
|
989
|
+
function isLoopbackHostname(hostname) {
|
|
990
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
991
|
+
const parts = hostname.split(".");
|
|
992
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
993
|
+
}
|
|
994
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
995
|
+
return trustedHosts.some((entry) => {
|
|
996
|
+
const parsed = parseAuthority(entry);
|
|
997
|
+
if (parsed === void 0) return false;
|
|
998
|
+
return canonicalAuthority(entry, parsed) === parsed.hostname ? parsed.hostname === hostUrl.hostname : parsed.host === hostUrl.host;
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
function isTrustedRequest(request, trustedHosts = []) {
|
|
1002
|
+
const host = header(request.headers, "host");
|
|
1003
|
+
if (host === void 0) return false;
|
|
1004
|
+
const hostUrl = parseAuthority(host);
|
|
1005
|
+
if (hostUrl === void 0) return false;
|
|
1006
|
+
const socketAddress = request.socket?.remoteAddress ?? request.info?.remoteAddress;
|
|
1007
|
+
if (isLoopbackHostname(hostUrl.hostname)) {
|
|
1008
|
+
if (typeof socketAddress === "string") {
|
|
1009
|
+
const loopbackPeer = socketAddress === "127.0.0.1" || socketAddress === "::1" || socketAddress === "::ffff:127.0.0.1";
|
|
1010
|
+
if (!loopbackPeer) return false;
|
|
1011
|
+
}
|
|
1012
|
+
} else if (!isTrustedAuthority(hostUrl, trustedHosts)) {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
1016
|
+
const origin = header(request.headers, "origin");
|
|
1017
|
+
if (origin === void 0) return true;
|
|
1018
|
+
try {
|
|
1019
|
+
return new URL(origin).host === hostUrl.host;
|
|
1020
|
+
} catch {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1767
1024
|
var CODE_STATUS = {
|
|
1768
1025
|
INVALID_CONFIG: 400,
|
|
1769
1026
|
INVALID_ASSET_ID: 400,
|
|
@@ -1779,7 +1036,7 @@ var CODE_STATUS = {
|
|
|
1779
1036
|
STORE_RECOVERY_REQUIRED: 409,
|
|
1780
1037
|
STORE_NOT_RECOVERING: 409
|
|
1781
1038
|
};
|
|
1782
|
-
function
|
|
1039
|
+
function sendJson(response, status, value, extraHeaders = {}) {
|
|
1783
1040
|
response.writeHead(status, {
|
|
1784
1041
|
"content-type": "application/json; charset=utf-8",
|
|
1785
1042
|
"cache-control": "no-store",
|
|
@@ -1789,19 +1046,19 @@ function sendJson2(response, status, value, extraHeaders = {}) {
|
|
|
1789
1046
|
}
|
|
1790
1047
|
function sendError(response, error) {
|
|
1791
1048
|
const status = CODE_STATUS[error?.code] ?? 500;
|
|
1792
|
-
|
|
1049
|
+
sendJson(response, status, publicError(error));
|
|
1793
1050
|
}
|
|
1794
|
-
function
|
|
1051
|
+
function header(headers, name2) {
|
|
1795
1052
|
const value = headers?.[name2];
|
|
1796
1053
|
return typeof value === "string" ? value : Array.isArray(value) ? value[0] : void 0;
|
|
1797
1054
|
}
|
|
1798
|
-
function
|
|
1055
|
+
function method(request, response, expected) {
|
|
1799
1056
|
if (request.method === expected) return true;
|
|
1800
1057
|
response.writeHead(405, { allow: expected });
|
|
1801
1058
|
response.end();
|
|
1802
1059
|
return false;
|
|
1803
1060
|
}
|
|
1804
|
-
async function
|
|
1061
|
+
async function readJsonBody(request, limit = 128 * 1024) {
|
|
1805
1062
|
const type = String(request.headers["content-type"] ?? "").toLowerCase();
|
|
1806
1063
|
if (!type.startsWith("application/json")) throw codedError("INVALID_CONFIG", "content-type must be application/json");
|
|
1807
1064
|
const chunks = [];
|
|
@@ -1836,7 +1093,7 @@ async function readRawBody(request, limit, overflowCode = "UPLOAD_TOO_LARGE") {
|
|
|
1836
1093
|
return Buffer.concat(chunks);
|
|
1837
1094
|
}
|
|
1838
1095
|
function decodeDisplayName(request) {
|
|
1839
|
-
const raw =
|
|
1096
|
+
const raw = header(request.headers, "x-filename");
|
|
1840
1097
|
if (raw === void 0 || raw === "") return "wallpaper";
|
|
1841
1098
|
try {
|
|
1842
1099
|
return decodeURIComponent(raw);
|
|
@@ -1849,7 +1106,7 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1849
1106
|
const disposers = [];
|
|
1850
1107
|
const fence = (request, response) => {
|
|
1851
1108
|
if (isTrustedRequest(request, trustedHosts)) return true;
|
|
1852
|
-
|
|
1109
|
+
sendJson(response, 403, { error: "trusted DSH Web request required" });
|
|
1853
1110
|
return false;
|
|
1854
1111
|
};
|
|
1855
1112
|
try {
|
|
@@ -1858,12 +1115,12 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1858
1115
|
path: "/dsh-skins/config",
|
|
1859
1116
|
handler: async (request, response) => {
|
|
1860
1117
|
if (!fence(request, response)) return;
|
|
1861
|
-
if (request.method === "GET") return
|
|
1862
|
-
if (!
|
|
1118
|
+
if (request.method === "GET") return sendJson(response, 200, store.snapshot());
|
|
1119
|
+
if (!method(request, response, "PATCH")) return;
|
|
1863
1120
|
try {
|
|
1864
|
-
const body = await
|
|
1121
|
+
const body = await readJsonBody(request);
|
|
1865
1122
|
const result = await store.applyOperations(body);
|
|
1866
|
-
return
|
|
1123
|
+
return sendJson(response, 200, result);
|
|
1867
1124
|
} catch (error) {
|
|
1868
1125
|
return sendError(response, error);
|
|
1869
1126
|
}
|
|
@@ -1875,9 +1132,9 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1875
1132
|
path: "/dsh-skins/recovery",
|
|
1876
1133
|
handler: async (request, response) => {
|
|
1877
1134
|
if (!fence(request, response)) return;
|
|
1878
|
-
if (!
|
|
1135
|
+
if (!method(request, response, "POST")) return;
|
|
1879
1136
|
try {
|
|
1880
|
-
return
|
|
1137
|
+
return sendJson(response, 200, await store.confirmRecovery());
|
|
1881
1138
|
} catch (error) {
|
|
1882
1139
|
return sendError(response, error);
|
|
1883
1140
|
}
|
|
@@ -1889,16 +1146,16 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1889
1146
|
path: "/dsh-skins/library",
|
|
1890
1147
|
handler: async (request, response) => {
|
|
1891
1148
|
if (!fence(request, response)) return;
|
|
1892
|
-
if (request.method === "GET") return
|
|
1893
|
-
if (!
|
|
1149
|
+
if (request.method === "GET") return sendJson(response, 200, store.snapshot());
|
|
1150
|
+
if (!method(request, response, "POST")) return;
|
|
1894
1151
|
try {
|
|
1895
|
-
const declaredLength = Number(
|
|
1152
|
+
const declaredLength = Number(header(request.headers, "content-length") ?? "0");
|
|
1896
1153
|
if (declaredLength > GLOBAL_MAX_BYTES) throw codedError("UPLOAD_TOO_LARGE", "图片超过 20MB 上限");
|
|
1897
1154
|
const buffer = await readRawBody(request, GLOBAL_MAX_BYTES + 1024);
|
|
1898
1155
|
const displayName = decodeDisplayName(request);
|
|
1899
|
-
const declaredMime =
|
|
1156
|
+
const declaredMime = header(request.headers, "content-type");
|
|
1900
1157
|
const result = await store.uploadAsset(buffer, { displayName, declaredMime });
|
|
1901
|
-
return
|
|
1158
|
+
return sendJson(response, 201, result);
|
|
1902
1159
|
} catch (error) {
|
|
1903
1160
|
return sendError(response, error);
|
|
1904
1161
|
}
|
|
@@ -1910,15 +1167,15 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1910
1167
|
path: "/dsh-skins/library",
|
|
1911
1168
|
handler: async (request, response) => {
|
|
1912
1169
|
if (!fence(request, response)) return;
|
|
1913
|
-
if (!
|
|
1170
|
+
if (!method(request, response, "DELETE")) return;
|
|
1914
1171
|
const path = String(request.url ?? "").split("?")[0];
|
|
1915
1172
|
const match = /\/dsh-skins\/library\/([^/]+)$/.exec(path);
|
|
1916
1173
|
const suffix = match?.[1] ?? "";
|
|
1917
1174
|
if (!ASSET_ID_PATTERN.test(suffix)) {
|
|
1918
|
-
return
|
|
1175
|
+
return sendJson(response, 400, { error: "invalid asset id", code: "INVALID_ASSET_ID" });
|
|
1919
1176
|
}
|
|
1920
1177
|
try {
|
|
1921
|
-
return
|
|
1178
|
+
return sendJson(response, 200, await store.deleteAsset(suffix));
|
|
1922
1179
|
} catch (error) {
|
|
1923
1180
|
return sendError(response, error);
|
|
1924
1181
|
}
|
|
@@ -1930,11 +1187,11 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1930
1187
|
path: assetsBasePath,
|
|
1931
1188
|
handler: async (request, response) => {
|
|
1932
1189
|
if (!fence(request, response)) return;
|
|
1933
|
-
if (!
|
|
1190
|
+
if (!method(request, response, "GET")) return;
|
|
1934
1191
|
try {
|
|
1935
1192
|
const blob = store.serveAsset(String(request.url ?? ""));
|
|
1936
1193
|
if (blob === null) {
|
|
1937
|
-
return
|
|
1194
|
+
return sendJson(response, 404, { error: "asset not found", code: "ASSET_NOT_FOUND" });
|
|
1938
1195
|
}
|
|
1939
1196
|
response.writeHead(200, {
|
|
1940
1197
|
"content-type": blob.meta.mime,
|
|
@@ -1967,60 +1224,24 @@ function mountPersonalizationRoutes(host, options) {
|
|
|
1967
1224
|
|
|
1968
1225
|
// src/index.js
|
|
1969
1226
|
var name = "@iasiv5/dsh-skins";
|
|
1970
|
-
function packageRoot() {
|
|
1971
|
-
return join3(dirname4(fileURLToPath(import.meta.url)), "..");
|
|
1972
|
-
}
|
|
1973
|
-
function packageVersion() {
|
|
1974
|
-
const manifest = JSON.parse(readFileSync3(join3(packageRoot(), "package.json"), "utf8"));
|
|
1975
|
-
if (typeof manifest.version !== "string") throw new Error("dsh-skins package.json 缺少版本号");
|
|
1976
|
-
return manifest.version;
|
|
1977
|
-
}
|
|
1978
1227
|
function dshHome() {
|
|
1979
|
-
return process.env.DSH_HOME?.trim() ||
|
|
1228
|
+
return process.env.DSH_HOME?.trim() || join2(homedir(), ".dsh");
|
|
1980
1229
|
}
|
|
1981
1230
|
function apply(ctx) {
|
|
1982
|
-
ctx.inject(["webServer", "
|
|
1231
|
+
ctx.inject(["webServer", "webRuntime"], (hostContext) => {
|
|
1983
1232
|
const root = dshHome();
|
|
1984
|
-
const profileDir = join3(root, "profiles", "web");
|
|
1985
|
-
const updater = createSelfUpdater({
|
|
1986
|
-
profileDir,
|
|
1987
|
-
cacheFile: join3(root, "dsh-skins", "update-cache.json"),
|
|
1988
|
-
currentVersion: packageVersion()
|
|
1989
|
-
}, {
|
|
1990
|
-
runner: runDshPlugin
|
|
1991
|
-
});
|
|
1992
|
-
const restart = createRestartScheduler(ctx.get("appExit"));
|
|
1993
1233
|
const personalization = createPersonalizationStore({
|
|
1994
|
-
dataDir:
|
|
1234
|
+
dataDir: join2(root, "dsh-skins")
|
|
1995
1235
|
});
|
|
1996
1236
|
hostContext.effect(() => {
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
restart,
|
|
2003
|
-
agents: hostContext.agents,
|
|
2004
|
-
trustedHosts: hostContext.webRuntime.trustedHosts
|
|
2005
|
-
});
|
|
2006
|
-
disposePersonalization = mountPersonalizationRoutes(hostContext, {
|
|
2007
|
-
store: personalization,
|
|
2008
|
-
trustedHosts: hostContext.webRuntime.trustedHosts
|
|
2009
|
-
});
|
|
2010
|
-
} catch (error) {
|
|
2011
|
-
disposePersonalization?.();
|
|
2012
|
-
disposeUpdate?.();
|
|
2013
|
-
throw error;
|
|
2014
|
-
}
|
|
2015
|
-
return () => {
|
|
2016
|
-
disposePersonalization();
|
|
2017
|
-
disposeUpdate();
|
|
2018
|
-
};
|
|
2019
|
-
}, "dsh-skins: self-update routes");
|
|
1237
|
+
return mountPersonalizationRoutes(hostContext, {
|
|
1238
|
+
store: personalization,
|
|
1239
|
+
trustedHosts: hostContext.webRuntime.trustedHosts
|
|
1240
|
+
});
|
|
1241
|
+
}, "dsh-skins: personalization routes");
|
|
2020
1242
|
});
|
|
2021
1243
|
}
|
|
2022
1244
|
export {
|
|
2023
1245
|
apply,
|
|
2024
|
-
createSelfUpdater,
|
|
2025
1246
|
name
|
|
2026
1247
|
};
|