beauticode-dsh 1.0.2 → 1.0.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/README.zh-CN.md +1 -1
- package/agent.mjs +33 -0
- package/bin/beauticode-dsh +7 -252
- package/cli.js +185 -30
- package/console.js +15 -0
- package/control-client.mjs +75 -6
- package/cordis.patch.yml +1 -1
- package/gallery-host.mjs +253 -0
- package/gallery.js +178 -0
- package/index.mjs +34 -1
- package/package.json +4 -1
- package/skin-center.json +3 -0
- package/ui-host.mjs +17 -0
- package/vendor/adapter-dsh/session.js +8 -1
- package/vendor/adapter-dsh/tray-handoff.js +11 -13
- package/vendor/core/apply-transaction.js +19 -7
- package/vendor/core/background-store.js +186 -47
- package/vendor/core/error-message.js +6 -0
- package/vendor/core/file-lock.js +34 -13
- package/vendor/core/index.js +2 -0
- package/vendor/core/media-server.js +23 -9
- package/vendor/core/process-liveness.js +205 -0
- package/vendor/core/skin-pack.js +64 -0
package/control-client.mjs
CHANGED
|
@@ -90,6 +90,47 @@ export function isPidAlive(pid) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
let livenessModulePromise;
|
|
94
|
+
|
|
95
|
+
function loadCoreLiveness() {
|
|
96
|
+
if (!livenessModulePromise) {
|
|
97
|
+
livenessModulePromise = (async () => {
|
|
98
|
+
const candidates = [
|
|
99
|
+
"@beauticode/core",
|
|
100
|
+
new URL("./vendor/core/index.js", import.meta.url).href,
|
|
101
|
+
new URL("../../packages/core/dist/index.js", import.meta.url).href,
|
|
102
|
+
];
|
|
103
|
+
for (const specifier of candidates) {
|
|
104
|
+
try {
|
|
105
|
+
const mod = await import(specifier);
|
|
106
|
+
if (typeof mod.isRecordedPidLive === "function") return mod;
|
|
107
|
+
} catch {
|
|
108
|
+
/* try the next resolution path */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
})();
|
|
113
|
+
}
|
|
114
|
+
return livenessModulePromise;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function isLiveRecordedPid(pid, startedAt, mtimeMs) {
|
|
118
|
+
if (!isPidAlive(pid)) return false;
|
|
119
|
+
const recorded =
|
|
120
|
+
typeof startedAt === "string" && startedAt
|
|
121
|
+
? startedAt
|
|
122
|
+
: Number.isFinite(mtimeMs)
|
|
123
|
+
? new Date(mtimeMs).toISOString()
|
|
124
|
+
: null;
|
|
125
|
+
try {
|
|
126
|
+
const core = await loadCoreLiveness();
|
|
127
|
+
if (core) return await core.isRecordedPidLive(pid, recorded);
|
|
128
|
+
} catch {
|
|
129
|
+
/* fall back to the cheap PID check */
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
93
134
|
export function stripPathQuotes(value) {
|
|
94
135
|
const text = String(value ?? "").trim();
|
|
95
136
|
if (text.length >= 2) {
|
|
@@ -195,6 +236,7 @@ export async function writeDshControlFile(opts) {
|
|
|
195
236
|
pid,
|
|
196
237
|
url: new URL(url).origin,
|
|
197
238
|
token,
|
|
239
|
+
startedAt: new Date().toISOString(),
|
|
198
240
|
});
|
|
199
241
|
}
|
|
200
242
|
|
|
@@ -219,6 +261,7 @@ export async function writeSessionHostFile(opts) {
|
|
|
219
261
|
pid,
|
|
220
262
|
url: new URL(url).origin,
|
|
221
263
|
token,
|
|
264
|
+
startedAt: new Date().toISOString(),
|
|
222
265
|
});
|
|
223
266
|
}
|
|
224
267
|
|
|
@@ -253,8 +296,11 @@ export async function removeDshControlFile(opts) {
|
|
|
253
296
|
export async function readDshControlFile(dataRoot, opts = {}) {
|
|
254
297
|
const file = controlFilePath(dataRoot);
|
|
255
298
|
let raw;
|
|
299
|
+
let mtimeMs = 0;
|
|
256
300
|
try {
|
|
257
|
-
|
|
301
|
+
const [text, stat] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
|
|
302
|
+
raw = text;
|
|
303
|
+
mtimeMs = stat.mtimeMs;
|
|
258
304
|
} catch (error) {
|
|
259
305
|
if (error && typeof error === "object" && error.code === "ENOENT") return null;
|
|
260
306
|
throw error;
|
|
@@ -277,13 +323,19 @@ export async function readDshControlFile(dataRoot, opts = {}) {
|
|
|
277
323
|
) {
|
|
278
324
|
return null;
|
|
279
325
|
}
|
|
280
|
-
if (
|
|
326
|
+
if (
|
|
327
|
+
!opts.allowDead &&
|
|
328
|
+
!(await isLiveRecordedPid(parsed.pid, parsed.startedAt, mtimeMs))
|
|
329
|
+
) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
281
332
|
return {
|
|
282
333
|
schema: CONTROL_SCHEMA,
|
|
283
334
|
host: "dsh",
|
|
284
335
|
pid: parsed.pid,
|
|
285
336
|
url: new URL(parsed.url).origin,
|
|
286
337
|
token: parsed.token,
|
|
338
|
+
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null,
|
|
287
339
|
};
|
|
288
340
|
}
|
|
289
341
|
|
|
@@ -305,8 +357,11 @@ export async function removeSessionHostFile(opts) {
|
|
|
305
357
|
export async function readSessionHostFile(dataRoot, opts = {}) {
|
|
306
358
|
const file = sessionHostFilePath(dataRoot);
|
|
307
359
|
let raw;
|
|
360
|
+
let mtimeMs = 0;
|
|
308
361
|
try {
|
|
309
|
-
|
|
362
|
+
const [text, stat] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
|
|
363
|
+
raw = text;
|
|
364
|
+
mtimeMs = stat.mtimeMs;
|
|
310
365
|
} catch (error) {
|
|
311
366
|
if (error && typeof error === "object" && error.code === "ENOENT") return null;
|
|
312
367
|
throw error;
|
|
@@ -329,21 +384,30 @@ export async function readSessionHostFile(dataRoot, opts = {}) {
|
|
|
329
384
|
) {
|
|
330
385
|
return null;
|
|
331
386
|
}
|
|
332
|
-
if (
|
|
387
|
+
if (
|
|
388
|
+
!opts.allowDead &&
|
|
389
|
+
!(await isLiveRecordedPid(parsed.pid, parsed.startedAt, mtimeMs))
|
|
390
|
+
) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
333
393
|
return {
|
|
334
394
|
schema: SESSION_HOST_SCHEMA,
|
|
335
395
|
host: parsed.host,
|
|
336
396
|
pid: parsed.pid,
|
|
337
397
|
url: new URL(parsed.url).origin,
|
|
338
398
|
token: parsed.token,
|
|
399
|
+
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null,
|
|
339
400
|
};
|
|
340
401
|
}
|
|
341
402
|
|
|
342
403
|
export async function readTrayClaim(dataRoot, opts = {}) {
|
|
343
404
|
const file = trayClaimFilePath(dataRoot);
|
|
344
405
|
let raw;
|
|
406
|
+
let mtimeMs = 0;
|
|
345
407
|
try {
|
|
346
|
-
|
|
408
|
+
const [text, stat] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
|
|
409
|
+
raw = text;
|
|
410
|
+
mtimeMs = stat.mtimeMs;
|
|
347
411
|
} catch (error) {
|
|
348
412
|
if (error && typeof error === "object" && error.code === "ENOENT") return null;
|
|
349
413
|
throw error;
|
|
@@ -362,7 +426,12 @@ export async function readTrayClaim(dataRoot, opts = {}) {
|
|
|
362
426
|
) {
|
|
363
427
|
return null;
|
|
364
428
|
}
|
|
365
|
-
if (
|
|
429
|
+
if (
|
|
430
|
+
!opts.allowDead &&
|
|
431
|
+
!(await isLiveRecordedPid(parsed.pid, parsed.startedAt, mtimeMs))
|
|
432
|
+
) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
366
435
|
return {
|
|
367
436
|
schema: TRAY_CLAIM_SCHEMA,
|
|
368
437
|
pid: parsed.pid,
|
package/cordis.patch.yml
CHANGED
package/gallery-host.mjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
|
|
12
|
+
const MAX_IMAGE_BYTES = 18 * 1024 * 1024;
|
|
13
|
+
const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
|
|
14
|
+
const INSTALL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
15
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
16
|
+
|
|
17
|
+
export function isSafeSkinId(id) {
|
|
18
|
+
return typeof id === "string" && SKIN_ID.test(id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeSkinCenterUrl(value) {
|
|
22
|
+
if (typeof value !== "string") return null;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (!trimmed) return null;
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(trimmed);
|
|
27
|
+
if (url.username || url.password || url.hash) return null;
|
|
28
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
29
|
+
if (url.protocol === "http:" && !LOOPBACK.has(url.hostname.toLowerCase())) return null;
|
|
30
|
+
const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
31
|
+
return `${url.origin}${pathname}`;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readBundledSkinCenterUrl() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(await fsp.readFile(path.join(here, "skin-center.json"), "utf8"));
|
|
40
|
+
return normalizeSkinCenterUrl(raw.url);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function resolveConfiguredSkinCenterUrl() {
|
|
47
|
+
return (
|
|
48
|
+
normalizeSkinCenterUrl(process.env.BEAUTICODE_SKIN_CENTER) ??
|
|
49
|
+
(await readBundledSkinCenterUrl())
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function skinUrl(center, id, part = "") {
|
|
54
|
+
const origin = normalizeSkinCenterUrl(center);
|
|
55
|
+
if (!origin || !isSafeSkinId(id)) {
|
|
56
|
+
throw new Error("Skin center is not configured.");
|
|
57
|
+
}
|
|
58
|
+
return part ? `${origin}/api/skins/${id}/${part}` : `${origin}/api/skins/${id}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress } = {}) {
|
|
62
|
+
const expected = new URL(url);
|
|
63
|
+
if (expectedOrigin && expected.origin !== expectedOrigin) {
|
|
64
|
+
throw new Error("Skin media download host mismatch.");
|
|
65
|
+
}
|
|
66
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
67
|
+
if (!response.ok || !response.body) {
|
|
68
|
+
throw new Error("Skin media download failed.");
|
|
69
|
+
}
|
|
70
|
+
const finalUrl = new URL(response.url);
|
|
71
|
+
if (finalUrl.origin !== expected.origin) {
|
|
72
|
+
throw new Error("Skin media download host mismatch.");
|
|
73
|
+
}
|
|
74
|
+
const length = Number(response.headers.get("content-length"));
|
|
75
|
+
if (Number.isFinite(length) && length > maxBytes) {
|
|
76
|
+
throw new Error("Skin media download exceeded the size limit.");
|
|
77
|
+
}
|
|
78
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
79
|
+
let size = 0;
|
|
80
|
+
const limiter = new Transform({
|
|
81
|
+
transform(chunk, _enc, callback) {
|
|
82
|
+
size += chunk.length;
|
|
83
|
+
if (size > maxBytes) {
|
|
84
|
+
callback(new Error("Skin media download exceeded the size limit."));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
onProgress?.(size, Number.isFinite(length) ? length : 0);
|
|
88
|
+
callback(null, chunk);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(dest));
|
|
92
|
+
return { bytes: size };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extensionOf(url, fallback) {
|
|
96
|
+
try {
|
|
97
|
+
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
|
98
|
+
if (ext) return ext;
|
|
99
|
+
} catch {
|
|
100
|
+
/* use fallback */
|
|
101
|
+
}
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createGalleryHandlers({ dataRoot, actions }) {
|
|
106
|
+
async function importTheme(input) {
|
|
107
|
+
if (typeof actions.importTheme === "function") {
|
|
108
|
+
return actions.importTheme(input);
|
|
109
|
+
}
|
|
110
|
+
throw new Error("当前引擎不支持导入皮肤。");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
async config(req, res, sendJson, isSameOrigin) {
|
|
115
|
+
if (req.method !== "GET") {
|
|
116
|
+
res.writeHead(405).end();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!isSameOrigin(req)) {
|
|
120
|
+
res.writeHead(403).end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const url = await resolveConfiguredSkinCenterUrl();
|
|
124
|
+
sendJson(res, 200, { ok: true, url, enabled: Boolean(url) });
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async catalog(req, res, sendJson, isSameOrigin) {
|
|
128
|
+
if (req.method !== "GET") {
|
|
129
|
+
res.writeHead(405).end();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!isSameOrigin(req)) {
|
|
133
|
+
res.writeHead(403).end();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const center = await resolveConfiguredSkinCenterUrl();
|
|
137
|
+
if (!center) {
|
|
138
|
+
sendJson(res, 200, {
|
|
139
|
+
ok: false,
|
|
140
|
+
error: "尚未配置皮肤中心地址。",
|
|
141
|
+
skins: [],
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const incoming = new URL(req.url || "/", "http://127.0.0.1");
|
|
146
|
+
const target = new URL("/api/catalog", `${center}/`);
|
|
147
|
+
target.search = incoming.search;
|
|
148
|
+
const response = await fetch(target, { headers: { accept: "application/json" } });
|
|
149
|
+
const body = await response.json().catch(() => null);
|
|
150
|
+
if (!response.ok || !body || body.ok === false) {
|
|
151
|
+
sendJson(res, 422, {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: body?.error || "无法读取皮肤目录。",
|
|
154
|
+
skins: [],
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
sendJson(res, 200, {
|
|
159
|
+
ok: true,
|
|
160
|
+
skins: Array.isArray(body.skins) ? body.skins : [],
|
|
161
|
+
nextCursor: body.nextCursor ?? null,
|
|
162
|
+
url: center,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async install(req, res, sendJson, isSameOrigin, readJson) {
|
|
167
|
+
if (req.method !== "POST") {
|
|
168
|
+
res.writeHead(405).end();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!isSameOrigin(req)) {
|
|
172
|
+
res.writeHead(403).end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const body = await readJson(req);
|
|
176
|
+
const id = String(body.id ?? "").trim();
|
|
177
|
+
if (!isSafeSkinId(id)) {
|
|
178
|
+
sendJson(res, 400, { ok: false, error: "皮肤 ID 无效。" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const center = await resolveConfiguredSkinCenterUrl();
|
|
182
|
+
if (!center) {
|
|
183
|
+
sendJson(res, 422, { ok: false, error: "尚未配置皮肤中心地址。" });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const origin = new URL(center).origin;
|
|
187
|
+
res.writeHead(200, {
|
|
188
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
189
|
+
"cache-control": "no-store",
|
|
190
|
+
});
|
|
191
|
+
const write = (payload) => {
|
|
192
|
+
if (!res.writableEnded) res.write(`${JSON.stringify(payload)}\n`);
|
|
193
|
+
};
|
|
194
|
+
const tmpDir = path.join(dataRoot, "tmp", "gallery", `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
|
|
195
|
+
try {
|
|
196
|
+
write({ phase: "fetch" });
|
|
197
|
+
const metaRes = await fetch(skinUrl(center, id), { headers: { accept: "application/json" } });
|
|
198
|
+
const meta = await metaRes.json().catch(() => null);
|
|
199
|
+
const skin = meta?.skin;
|
|
200
|
+
if (!metaRes.ok || !skin || skin.status && skin.status !== "approved") {
|
|
201
|
+
throw new Error("Skin is not available for download.");
|
|
202
|
+
}
|
|
203
|
+
await fsp.mkdir(tmpDir, { recursive: true });
|
|
204
|
+
const imageUrl = skinUrl(center, id, "image");
|
|
205
|
+
const imagePath = path.join(tmpDir, `image${extensionOf(imageUrl, ".png")}`);
|
|
206
|
+
write({ phase: "download", part: "image" });
|
|
207
|
+
await downloadToFile(imageUrl, imagePath, {
|
|
208
|
+
maxBytes: MAX_IMAGE_BYTES,
|
|
209
|
+
expectedOrigin: origin,
|
|
210
|
+
onProgress: (done, total) => write({ phase: "download", part: "image", done, total }),
|
|
211
|
+
});
|
|
212
|
+
let videoPath;
|
|
213
|
+
if (skin.type === "video") {
|
|
214
|
+
const videoUrl = skinUrl(center, id, "video");
|
|
215
|
+
videoPath = path.join(tmpDir, "background.mp4");
|
|
216
|
+
write({ phase: "download", part: "video" });
|
|
217
|
+
await downloadToFile(videoUrl, videoPath, {
|
|
218
|
+
maxBytes: MAX_VIDEO_BYTES,
|
|
219
|
+
expectedOrigin: origin,
|
|
220
|
+
onProgress: (done, total) => write({ phase: "download", part: "video", done, total }),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
write({ phase: "import" });
|
|
224
|
+
const imported = await importTheme({
|
|
225
|
+
name: String(skin.name || id).slice(0, 80),
|
|
226
|
+
imagePath,
|
|
227
|
+
...(videoPath ? { videoPath } : {}),
|
|
228
|
+
...(skin.effects ? { effects: skin.effects } : {}),
|
|
229
|
+
source: { kind: "skin-center", skinId: id, centerUrl: center },
|
|
230
|
+
});
|
|
231
|
+
write({ phase: "apply" });
|
|
232
|
+
const applied = await actions.useTheme(imported.theme?.id || imported.id, undefined);
|
|
233
|
+
fetch(skinUrl(center, id, "download"), { method: "POST" }).catch(() => {});
|
|
234
|
+
write({
|
|
235
|
+
ok: true,
|
|
236
|
+
phase: "done",
|
|
237
|
+
theme: imported.theme || imported,
|
|
238
|
+
message: applied.message || `已安装并应用「${skin.name}」。`,
|
|
239
|
+
});
|
|
240
|
+
} catch (error) {
|
|
241
|
+
write({
|
|
242
|
+
ok: false,
|
|
243
|
+
error: error instanceof Error ? error.message : String(error),
|
|
244
|
+
});
|
|
245
|
+
} finally {
|
|
246
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
247
|
+
res.end();
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const GALLERY_INSTALL_TIMEOUT_MS = INSTALL_TIMEOUT_MS;
|
package/gallery.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
"use strict";
|
|
3
|
+
if (window.__beauticodeGalleryLoaded) return;
|
|
4
|
+
window.__beauticodeGalleryLoaded = true;
|
|
5
|
+
|
|
6
|
+
const style = document.createElement("style");
|
|
7
|
+
style.textContent = `
|
|
8
|
+
#beauticode-gallery{position:fixed;inset:0;z-index:3000;display:flex;align-items:center;justify-content:center;background:rgba(11,13,18,.62)}
|
|
9
|
+
#beauticode-gallery[hidden]{display:none}
|
|
10
|
+
#beauticode-gallery .bcg-panel{width:min(880px,calc(100vw - 32px));height:min(640px,calc(100vh - 32px));display:flex;flex-direction:column;border:1px solid rgba(255,255,255,.08);border-radius:18px;background:#2c323c;color:#e8eaed;box-shadow:0 16px 48px rgba(0,0,0,.4);overflow:hidden}
|
|
11
|
+
body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.08)}
|
|
12
|
+
#beauticode-gallery .bcg-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid rgba(255,255,255,.08)}
|
|
13
|
+
#beauticode-gallery .bcg-head h2{margin:0;font-size:15px;font-weight:600}
|
|
14
|
+
#beauticode-gallery .bcg-head input,#beauticode-gallery .bcg-head select{height:32px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(255,255,255,.06);color:inherit;padding:0 8px}
|
|
15
|
+
#beauticode-gallery .bcg-grid{flex:1;overflow:auto;padding:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;align-content:start}
|
|
16
|
+
#beauticode-gallery .bcg-card{display:block;border:none;padding:0;border-radius:12px;overflow:hidden;background:#232830;color:inherit;text-align:left;cursor:pointer}
|
|
17
|
+
#beauticode-gallery .bcg-card img{width:100%;aspect-ratio:16/10;object-fit:cover;display:block;background:#111}
|
|
18
|
+
#beauticode-gallery .bcg-card span{display:block;padding:8px 10px;font-size:13px}
|
|
19
|
+
#beauticode-gallery .bcg-msg,#beauticode-gallery .bcg-foot{padding:0 14px 12px;color:#9aa3ad;font-size:12px}
|
|
20
|
+
#beauticode-gallery .bcg-close{margin-left:auto}
|
|
21
|
+
#beauticode-gallery .bcg-btn{height:32px;padding:0 10px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(255,255,255,.06);color:inherit;cursor:pointer}
|
|
22
|
+
#beauticode-gallery .bcg-btn.primary{background:#4d6bfe;border-color:transparent}
|
|
23
|
+
`;
|
|
24
|
+
document.head.append(style);
|
|
25
|
+
|
|
26
|
+
const host = document.createElement("div");
|
|
27
|
+
host.id = "beauticode-gallery";
|
|
28
|
+
host.hidden = true;
|
|
29
|
+
host.innerHTML =
|
|
30
|
+
'<div class="bcg-panel" role="dialog" aria-modal="true" aria-labelledby="bcg-title">' +
|
|
31
|
+
'<div class="bcg-head">' +
|
|
32
|
+
'<h2 id="bcg-title">皮肤中心</h2>' +
|
|
33
|
+
'<input class="bcg-q" placeholder="搜索" />' +
|
|
34
|
+
'<select class="bcg-type"><option value="">全部</option><option value="image">图片</option><option value="video">视频</option></select>' +
|
|
35
|
+
'<button type="button" class="bcg-btn bcg-close">关闭</button>' +
|
|
36
|
+
"</div>" +
|
|
37
|
+
'<div class="bcg-grid"></div>' +
|
|
38
|
+
'<p class="bcg-msg"></p>' +
|
|
39
|
+
'<p class="bcg-foot"></p>' +
|
|
40
|
+
"</div>";
|
|
41
|
+
document.body.append(host);
|
|
42
|
+
|
|
43
|
+
const grid = host.querySelector(".bcg-grid");
|
|
44
|
+
const msg = host.querySelector(".bcg-msg");
|
|
45
|
+
const foot = host.querySelector(".bcg-foot");
|
|
46
|
+
const queryInput = host.querySelector(".bcg-q");
|
|
47
|
+
const typeSelect = host.querySelector(".bcg-type");
|
|
48
|
+
let centerUrl = "";
|
|
49
|
+
let busy = false;
|
|
50
|
+
|
|
51
|
+
function escapeText(value) {
|
|
52
|
+
return String(value ?? "")
|
|
53
|
+
.replaceAll("&", "&")
|
|
54
|
+
.replaceAll("<", "<")
|
|
55
|
+
.replaceAll(">", ">");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function request(path, init) {
|
|
59
|
+
const response = await fetch(path, init);
|
|
60
|
+
if ((response.headers.get("content-type") || "").includes("ndjson")) {
|
|
61
|
+
return readNdjson(response);
|
|
62
|
+
}
|
|
63
|
+
const body = await response.json().catch(() => null);
|
|
64
|
+
if (!response.ok || body?.ok === false) {
|
|
65
|
+
throw new Error(body?.error || `请求失败(${response.status})`);
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readNdjson(response) {
|
|
71
|
+
const reader = response.body.getReader();
|
|
72
|
+
const decoder = new TextDecoder();
|
|
73
|
+
let buffer = "";
|
|
74
|
+
let last = null;
|
|
75
|
+
while (true) {
|
|
76
|
+
const { value, done } = await reader.read();
|
|
77
|
+
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
78
|
+
const lines = buffer.split("\n");
|
|
79
|
+
buffer = lines.pop() || "";
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (!line.trim()) continue;
|
|
82
|
+
last = JSON.parse(line);
|
|
83
|
+
if (last.phase === "download" && last.total) {
|
|
84
|
+
const pct = Math.round((last.done / last.total) * 100);
|
|
85
|
+
msg.textContent = `正在下载${last.part === "video" ? "视频" : "图片"} ${pct}%`;
|
|
86
|
+
} else if (last.phase === "import") {
|
|
87
|
+
msg.textContent = "正在写入本机主题…";
|
|
88
|
+
} else if (last.phase === "apply") {
|
|
89
|
+
msg.textContent = "正在应用到当前窗口…";
|
|
90
|
+
}
|
|
91
|
+
if (last.ok === false) throw new Error(last.error || "安装失败。");
|
|
92
|
+
}
|
|
93
|
+
if (done) break;
|
|
94
|
+
}
|
|
95
|
+
if (last?.ok) return last;
|
|
96
|
+
throw new Error(last?.error || "安装失败。");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function load() {
|
|
100
|
+
msg.textContent = "正在读取目录…";
|
|
101
|
+
const params = new URLSearchParams();
|
|
102
|
+
if (queryInput.value.trim()) params.set("q", queryInput.value.trim());
|
|
103
|
+
if (typeSelect.value) params.set("type", typeSelect.value);
|
|
104
|
+
const data = await request(`/__beauticode/ui/gallery/catalog?${params}`);
|
|
105
|
+
centerUrl = data.url || centerUrl;
|
|
106
|
+
grid.innerHTML = (data.skins || [])
|
|
107
|
+
.map(
|
|
108
|
+
(skin) =>
|
|
109
|
+
`<button type="button" class="bcg-card" data-id="${escapeText(skin.id)}">` +
|
|
110
|
+
`<img alt="" src="${escapeText(centerUrl)}/api/skins/${escapeText(skin.id)}/card">` +
|
|
111
|
+
`<span>${escapeText(skin.name)}${skin.type === "video" ? " · 视频" : ""}</span></button>`,
|
|
112
|
+
)
|
|
113
|
+
.join("");
|
|
114
|
+
msg.textContent = data.skins?.length ? "" : "目录是空的。";
|
|
115
|
+
foot.innerHTML = centerUrl
|
|
116
|
+
? `上传与审核在 <a href="${escapeText(centerUrl)}" target="_blank" rel="noreferrer">皮肤中心网站</a>。安装会下载到本机后再应用。`
|
|
117
|
+
: "未配置皮肤中心地址。在插件的 skin-center.json 或环境变量 BEAUTICODE_SKIN_CENTER 里填入你的域名。";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function open() {
|
|
121
|
+
host.hidden = false;
|
|
122
|
+
const config = await request("/__beauticode/ui/gallery/config");
|
|
123
|
+
centerUrl = config.url || "";
|
|
124
|
+
if (!config.enabled) {
|
|
125
|
+
grid.innerHTML = "";
|
|
126
|
+
msg.textContent = "尚未配置皮肤中心地址。";
|
|
127
|
+
foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。";
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await load();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function close() {
|
|
134
|
+
host.hidden = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
host.querySelector(".bcg-close").addEventListener("click", close);
|
|
138
|
+
host.addEventListener("click", (event) => {
|
|
139
|
+
if (event.target === host) close();
|
|
140
|
+
});
|
|
141
|
+
queryInput.addEventListener("keydown", (event) => {
|
|
142
|
+
if (event.key === "Enter") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
void load();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
typeSelect.addEventListener("change", () => void load());
|
|
148
|
+
grid.addEventListener("click", (event) => {
|
|
149
|
+
const card = event.target.closest("[data-id]");
|
|
150
|
+
if (!card || busy) return;
|
|
151
|
+
const id = card.getAttribute("data-id");
|
|
152
|
+
busy = true;
|
|
153
|
+
msg.textContent = "开始安装…";
|
|
154
|
+
request("/__beauticode/ui/gallery/install", {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "content-type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ id }),
|
|
158
|
+
})
|
|
159
|
+
.then((result) => {
|
|
160
|
+
msg.textContent = result.message || "已安装。";
|
|
161
|
+
document.dispatchEvent(new CustomEvent("beauticode-gallery-installed"));
|
|
162
|
+
})
|
|
163
|
+
.catch((error) => {
|
|
164
|
+
msg.textContent = error instanceof Error ? error.message : String(error);
|
|
165
|
+
})
|
|
166
|
+
.finally(() => {
|
|
167
|
+
busy = false;
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
document.addEventListener("keydown", (event) => {
|
|
171
|
+
if (event.key === "Escape" && !host.hidden) {
|
|
172
|
+
event.stopPropagation();
|
|
173
|
+
close();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
window.BeauticodeGallery = { open, close };
|
|
178
|
+
})();
|
package/index.mjs
CHANGED
|
@@ -215,7 +215,8 @@ export function apply(ctx, config = {}) {
|
|
|
215
215
|
const script =
|
|
216
216
|
'<script defer src="/__beauticode/atmosphere.js"></script>' +
|
|
217
217
|
'<script defer src="/__beauticode/client.js" data-beauticode-bridge></script>' +
|
|
218
|
-
'<script defer src="/__beauticode/console.js"></script>'
|
|
218
|
+
'<script defer src="/__beauticode/console.js"></script>' +
|
|
219
|
+
'<script defer src="/__beauticode/gallery.js"></script>';
|
|
219
220
|
return html.includes("</body>")
|
|
220
221
|
? html.replace("</body>", `${script}</body>`)
|
|
221
222
|
: `${html}${script}`;
|
|
@@ -316,6 +317,23 @@ export function apply(ctx, config = {}) {
|
|
|
316
317
|
res.end(req.method === "HEAD" ? undefined : source);
|
|
317
318
|
},
|
|
318
319
|
}),
|
|
320
|
+
ctx.webServer.register({
|
|
321
|
+
kind: "exact",
|
|
322
|
+
path: "/__beauticode/gallery.js",
|
|
323
|
+
handler: async (req, res) => {
|
|
324
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
325
|
+
res.writeHead(405).end();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const source = await fs.readFile(path.join(here, "gallery.js"));
|
|
329
|
+
res.writeHead(200, {
|
|
330
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
331
|
+
"cache-control": "no-store",
|
|
332
|
+
"content-length": source.length,
|
|
333
|
+
});
|
|
334
|
+
res.end(req.method === "HEAD" ? undefined : source);
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
319
337
|
ctx.webServer.register({
|
|
320
338
|
kind: "exact",
|
|
321
339
|
path: "/__beauticode/console.js",
|
|
@@ -363,6 +381,21 @@ export function apply(ctx, config = {}) {
|
|
|
363
381
|
path: "/__beauticode/ui/preset",
|
|
364
382
|
handler: (req, res) => ui.applyPreset(req, res),
|
|
365
383
|
}),
|
|
384
|
+
ctx.webServer.register({
|
|
385
|
+
kind: "exact",
|
|
386
|
+
path: "/__beauticode/ui/gallery/config",
|
|
387
|
+
handler: (req, res) => ui.galleryConfig(req, res),
|
|
388
|
+
}),
|
|
389
|
+
ctx.webServer.register({
|
|
390
|
+
kind: "exact",
|
|
391
|
+
path: "/__beauticode/ui/gallery/catalog",
|
|
392
|
+
handler: (req, res) => ui.galleryCatalog(req, res),
|
|
393
|
+
}),
|
|
394
|
+
ctx.webServer.register({
|
|
395
|
+
kind: "exact",
|
|
396
|
+
path: "/__beauticode/ui/gallery/install",
|
|
397
|
+
handler: (req, res) => ui.galleryInstall(req, res),
|
|
398
|
+
}),
|
|
366
399
|
ctx.webServer.register({
|
|
367
400
|
kind: "exact",
|
|
368
401
|
path: "/__beauticode/events",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beauticode-dsh",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"index.mjs",
|
|
17
17
|
"client.js",
|
|
18
18
|
"console.js",
|
|
19
|
+
"gallery.js",
|
|
20
|
+
"gallery-host.mjs",
|
|
21
|
+
"skin-center.json",
|
|
19
22
|
"atmosphere.js",
|
|
20
23
|
"presets.mjs",
|
|
21
24
|
"agent.mjs",
|
package/skin-center.json
ADDED