beauticode-dsh 1.0.22 → 1.0.23

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/agent.mjs CHANGED
@@ -124,6 +124,23 @@ function unwrapApply(result, fallbackMode, message) {
124
124
  };
125
125
  }
126
126
 
127
+ // The raw manifest exposes a local source's absolute on-disk path. The status
128
+ // tool result is surfaced to the model, so only forward a safe summary.
129
+ function sanitizeBackground(background) {
130
+ if (!background) return null;
131
+ const safe = {
132
+ type: background.type,
133
+ ...(background.effects ? { effects: background.effects } : {}),
134
+ };
135
+ if (background.source) {
136
+ safe.source =
137
+ background.source.kind === "local"
138
+ ? { kind: "local" }
139
+ : { kind: "managed", file: background.source.file };
140
+ }
141
+ return safe;
142
+ }
143
+
127
144
  function presentStatus(status) {
128
145
  const background = status.manifest?.background ?? status.background ?? null;
129
146
  return {
@@ -133,7 +150,7 @@ function presentStatus(status) {
133
150
  fish: status.fish === true,
134
151
  muted: status.muted !== false,
135
152
  tone: status.tone ?? "dark",
136
- background,
153
+ background: sanitizeBackground(background),
137
154
  sourceMode: background
138
155
  ? background.source?.kind === "local"
139
156
  ? "local"
@@ -350,33 +367,36 @@ export function createBeauticodeActions(dataRootOrOptions) {
350
367
  const name = String(input?.name ?? "").trim();
351
368
  const imagePath = String(input?.imagePath ?? "").trim();
352
369
  if (!name || !imagePath) fail("导入皮肤必须提供名称和图片。");
353
- const body = {
354
- name,
355
- imagePath,
356
- };
357
- if (typeof input.videoPath === "string" && input.videoPath.trim()) {
358
- body.videoPath = input.videoPath.trim();
359
- }
360
- if (input.effects) body.effects = input.effects;
361
- if (input.source) body.source = input.source;
370
+ const source = input?.source === "managed" ? "managed" : "local";
371
+ const hasVideo = typeof input.videoPath === "string" && Boolean(input.videoPath.trim());
372
+ const themeInput = hasVideo
373
+ ? {
374
+ type: "video",
375
+ videoPath: input.videoPath.trim(),
376
+ imagePath,
377
+ source,
378
+ }
379
+ : { type: "image", imagePath, source };
380
+ if (!hasVideo && input.effects) themeInput.effects = input.effects;
362
381
  const resolved = await backend();
363
- if (resolved.kind === "tray") {
364
- const result = await request({
365
- method: "POST",
366
- path: "/theme/import",
367
- body,
368
- signal,
369
- timeoutMs: 30 * 60 * 1000,
370
- });
371
- if (!result || result.ok === false) fail(result?.error || "导入皮肤失败。");
372
- return {
373
- ok: true,
374
- theme: result.theme,
375
- message: `已保存皮肤「${result.theme.name}」。`,
376
- };
377
- }
378
- const theme = await resolved.session.importSavedTheme(body);
379
- return { ok: true, theme, message: `已保存皮肤「${theme.name}」。` };
382
+ // Gallery import applies-and-saves through the same atomic transaction as
383
+ // the normal image/video flow. There is no separate import-only endpoint:
384
+ // the tray exposes /theme/apply and DshSession exposes applyAndSaveTheme.
385
+ const result =
386
+ resolved.kind === "tray"
387
+ ? await request({
388
+ method: "POST",
389
+ path: "/theme/apply",
390
+ body: { name, input: themeInput },
391
+ signal,
392
+ timeoutMs: 30 * 60 * 1000,
393
+ })
394
+ : await resolved.session.applyAndSaveTheme(themeInput, name);
395
+ return unwrapApplyResult(
396
+ result,
397
+ hasVideo ? "video" : "image",
398
+ `已保存皮肤「${name}」。`,
399
+ );
380
400
  },
381
401
 
382
402
  async listThemes(signal) {
package/atmosphere.js CHANGED
@@ -1,4 +1,4 @@
1
- (() => {
1
+ (() => {
2
2
  "use strict";
3
3
 
4
4
  const STYLE_ID = "beauticode-atmosphere-style";
@@ -92,8 +92,8 @@ html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{
92
92
  }
93
93
  html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important}
94
94
  html[data-bc-gallery="true"] [class*="_fade"]{display:none!important}
95
- html[data-bc-gallery="true"] #beauticode-bg-stage img,
96
- html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
95
+ html[data-bc-gallery="true"] #beauticode-bg-stage img,
96
+ html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
97
97
  html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
98
98
  `;
99
99
  return style;
package/cli.js CHANGED
@@ -298,14 +298,26 @@ async function sameLinkTarget(link, dest) {
298
298
  return false;
299
299
  }
300
300
 
301
+ // A dangling symlink has a live directory entry even though fs.existsSync
302
+ // (which follows the link) reports false. Use lstat so leftover plugin links
303
+ // are detected and cleaned instead of making fsp.symlink fail with EEXIST.
304
+ async function entryExists(p) {
305
+ try {
306
+ await fsp.lstat(p);
307
+ return true;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+
301
313
  async function linkPluginIntoProfile(webProfile, dest) {
302
314
  const link = pluginLinkPath(webProfile);
303
315
  const legacy = legacyPluginLinkPath(webProfile);
304
- if (fs.existsSync(legacy)) {
316
+ if (await entryExists(legacy)) {
305
317
  await fsp.rm(legacy, { recursive: true, force: true });
306
318
  }
307
319
  await fsp.mkdir(path.dirname(link), { recursive: true });
308
- if (fs.existsSync(link)) {
320
+ if (await entryExists(link)) {
309
321
  if (await sameLinkTarget(link, dest)) return;
310
322
  await fsp.rm(link, { recursive: true, force: true });
311
323
  }
@@ -403,7 +415,7 @@ async function uninstall(opts) {
403
415
  removed.push("web package.json");
404
416
  }
405
417
  for (const link of [pluginLinkPath(webProfile), legacyPluginLinkPath(webProfile)]) {
406
- if (fs.existsSync(link)) {
418
+ if (await entryExists(link)) {
407
419
  await fsp.rm(link, { recursive: true, force: true });
408
420
  removed.push(link);
409
421
  }
package/client.js CHANGED
@@ -1543,7 +1543,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1543
1543
  activePayload === committedPayload &&
1544
1544
  renderPhase === "ready"
1545
1545
  ) {
1546
- await acknowledgeRender(committedPayload, true, true);
1546
+ await acknowledgeRender(committedPayload, true, true, null, {
1547
+ videoReady: mountedCurrentSlot()?.dataset.bcVideoReady === "true",
1548
+ });
1547
1549
  }
1548
1550
  } else {
1549
1551
  playbackBlocked = false;
@@ -1598,7 +1600,11 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1598
1600
  // A heartbeat is observational, not a second render verdict. Playback can
1599
1601
  // briefly pause while Chromium changes modes or refills an 8K buffer; do
1600
1602
  // not downgrade an already-rendered generation or fail a pending one.
1601
- void acknowledgeRender(committedPayload, true, true).catch(() => {});
1603
+ // Report the real first-frame state: a committed video that still only
1604
+ // shows its poster must not be acked as video-ready (see settleCommittedVideo).
1605
+ void acknowledgeRender(committedPayload, true, true, null, {
1606
+ videoReady: slot.dataset.bcVideoReady === "true",
1607
+ }).catch(() => {});
1602
1608
  }
1603
1609
  }, 1_000);
1604
1610
  })();
package/console.js CHANGED
@@ -128,12 +128,15 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
128
128
 
129
129
  function place() {
130
130
  const settings = findSettingsTrigger();
131
- if (!settings || !settings.parentElement) {
131
+ const row = settings?.parentElement;
132
+ const settingsArea = row?.parentElement;
133
+ const footArea = settingsArea?.parentElement;
134
+ if (!settings || !settingsArea || !footArea) {
132
135
  if (host.parentElement) host.remove();
133
136
  return;
134
137
  }
135
- if (host.parentElement !== settings.parentElement || host.nextElementSibling !== settings) {
136
- settings.parentElement.insertBefore(host, settings);
138
+ if (host.parentElement !== footArea || host.nextElementSibling !== settingsArea) {
139
+ footArea.insertBefore(host, settingsArea);
137
140
  }
138
141
  host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40);
139
142
  if (!pop.hidden) placePop();
@@ -252,7 +255,12 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
252
255
  return body;
253
256
  } catch (error) {
254
257
  if (controller?.signal.aborted) {
255
- throw new Error("背景操作超时,控件已恢复。原背景保持不变,请重试。");
258
+ // The browser only cancels its own fetch; a slow backend transaction
259
+ // (managed-video copy or verify) may still commit afterwards. Don't
260
+ // claim the previous background was preserved — report honestly.
261
+ throw new Error(
262
+ "背景操作超时,控件已恢复。操作可能仍在后台进行,请先查看当前背景状态再重试。",
263
+ );
256
264
  }
257
265
  throw error;
258
266
  } finally {
@@ -427,7 +435,9 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
427
435
  event.stopPropagation();
428
436
  setOpen(false);
429
437
  if (window.BeauticodeGallery) {
430
- void window.BeauticodeGallery.open();
438
+ window.BeauticodeGallery.open().catch((error) => {
439
+ showMessage(error instanceof Error ? error.message : String(error));
440
+ });
431
441
  return;
432
442
  }
433
443
  showMessage("皮肤中心脚本尚未加载。");
package/gallery-host.mjs CHANGED
@@ -12,6 +12,9 @@ const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
12
12
  const MAX_IMAGE_BYTES = 18 * 1024 * 1024;
13
13
  const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
14
14
  const INSTALL_TIMEOUT_MS = 30 * 60 * 1000;
15
+ // Small catalog/metadata requests get a tight budget; the media download uses
16
+ // INSTALL_TIMEOUT_MS so a large video on a slow link can still finish.
17
+ const REQUEST_TIMEOUT_MS = 30_000;
15
18
  const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1"]);
16
19
 
17
20
  export function isSafeSkinId(id) {
@@ -58,12 +61,19 @@ export function skinUrl(center, id, part = "") {
58
61
  return part ? `${origin}/api/skins/${id}/${part}` : `${origin}/api/skins/${id}`;
59
62
  }
60
63
 
61
- export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress } = {}) {
64
+ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress, signal } = {}) {
62
65
  const expected = new URL(url);
63
66
  if (expectedOrigin && expected.origin !== expectedOrigin) {
64
67
  throw new Error("Skin media download host mismatch.");
65
68
  }
66
- const response = await fetch(url, { redirect: "follow" });
69
+ // Bound every media download; a stalling skin center must not leave the
70
+ // gallery request open forever. Callers may pass an AbortSignal to cancel
71
+ // early when the browser client disconnects.
72
+ const timeoutSignal = AbortSignal.timeout(INSTALL_TIMEOUT_MS);
73
+ const response = await fetch(url, {
74
+ redirect: "follow",
75
+ signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
76
+ });
67
77
  if (!response.ok || !response.body) {
68
78
  throw new Error("Skin media download failed.");
69
79
  }
@@ -77,6 +87,8 @@ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onPr
77
87
  }
78
88
  await fsp.mkdir(path.dirname(dest), { recursive: true });
79
89
  let size = 0;
90
+ let lastReportedAt = 0;
91
+ let lastReportedBytes = 0;
80
92
  const limiter = new Transform({
81
93
  transform(chunk, _enc, callback) {
82
94
  size += chunk.length;
@@ -84,12 +96,26 @@ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onPr
84
96
  callback(new Error("Skin media download exceeded the size limit."));
85
97
  return;
86
98
  }
87
- onProgress?.(size, Number.isFinite(length) ? length : 0);
99
+ // Throttle progress frames: one every 250 ms or every 1 MiB, whichever
100
+ // comes first. A chunk-level frame floods the NDJSON stream on large
101
+ // videos and forces a DOM write per chunk in the browser client.
102
+ const now = Date.now();
103
+ if (now - lastReportedAt >= 250 || size - lastReportedBytes >= 1024 * 1024) {
104
+ lastReportedAt = now;
105
+ lastReportedBytes = size;
106
+ onProgress?.(size, Number.isFinite(length) ? length : 0);
107
+ }
88
108
  callback(null, chunk);
89
109
  },
110
+ flush(callback) {
111
+ if (size !== lastReportedBytes) {
112
+ onProgress?.(size, Number.isFinite(length) ? length : 0);
113
+ }
114
+ callback();
115
+ },
90
116
  });
91
117
  await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(dest));
92
- return { bytes: size };
118
+ return { bytes: size, contentType: response.headers.get("content-type") ?? null };
93
119
  }
94
120
 
95
121
  function extensionOf(url, fallback) {
@@ -102,6 +128,38 @@ function extensionOf(url, fallback) {
102
128
  return fallback;
103
129
  }
104
130
 
131
+ // Theme names are persisted via saveCurrentTheme, which rejects characters that
132
+ // are illegal in filenames. Normalize gallery skin display names the same way
133
+ // local filenames are normalized so a name like "Cyberpunk: Neon" still installs.
134
+ function normalizeThemeName(value, fallback = "主题") {
135
+ const name = String(value ?? "")
136
+ .replace(/[<>:"/\\|?*]/g, " ")
137
+ .replace(/[\u0000-\u001f]/g, "")
138
+ .replace(/\s+/g, " ")
139
+ .trim()
140
+ .slice(0, 80);
141
+ return name || fallback;
142
+ }
143
+
144
+ // The /image endpoint is extensionless; derive the on-disk suffix from the
145
+ // response content-type so JPEG/WebP are not mislabeled as .png (which the
146
+ // image validator would then reject on a magic-byte mismatch).
147
+ function extensionForContentType(contentType) {
148
+ const mime = String(contentType ?? "").split(";")[0].trim().toLowerCase();
149
+ switch (mime) {
150
+ case "image/jpeg":
151
+ return ".jpg";
152
+ case "image/png":
153
+ return ".png";
154
+ case "image/webp":
155
+ return ".webp";
156
+ case "image/avif":
157
+ return ".avif";
158
+ default:
159
+ return null;
160
+ }
161
+ }
162
+
105
163
  export function createGalleryHandlers({ dataRoot, actions }) {
106
164
  async function importTheme(input) {
107
165
  if (typeof actions.importTheme === "function") {
@@ -143,9 +201,24 @@ export function createGalleryHandlers({ dataRoot, actions }) {
143
201
  return;
144
202
  }
145
203
  const incoming = new URL(req.url || "/", "http://127.0.0.1");
146
- const target = new URL("/api/catalog", `${center}/`);
204
+ // Resolve relative to the center's own path so a base like
205
+ // https://host/beauticode keeps its prefix (skinUrl already does).
206
+ const target = new URL("api/catalog", `${center}/`);
147
207
  target.search = incoming.search;
148
- const response = await fetch(target, { headers: { accept: "application/json" } });
208
+ let response;
209
+ try {
210
+ response = await fetch(target, {
211
+ headers: { accept: "application/json" },
212
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
213
+ });
214
+ } catch (error) {
215
+ sendJson(res, 502, {
216
+ ok: false,
217
+ error: "无法连接皮肤中心,请稍后重试。",
218
+ skins: [],
219
+ });
220
+ return;
221
+ }
149
222
  const body = await response.json().catch(() => null);
150
223
  if (!response.ok || !body || body.ok === false) {
151
224
  sendJson(res, 422, {
@@ -192,9 +265,18 @@ export function createGalleryHandlers({ dataRoot, actions }) {
192
265
  if (!res.writableEnded) res.write(`${JSON.stringify(payload)}\n`);
193
266
  };
194
267
  const tmpDir = path.join(dataRoot, "tmp", "gallery", `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
268
+ // Cancel the download when the browser client disconnects (e.g. the UI
269
+ // timeout aborts its fetch); otherwise a slow skin download keeps going
270
+ // and can still commit a background after the controls recovered.
271
+ const clientAbort = new AbortController();
272
+ const onClose = () => clientAbort.abort(new Error("客户端已断开。"));
273
+ res.once("close", onClose);
195
274
  try {
196
275
  write({ phase: "fetch" });
197
- const metaRes = await fetch(skinUrl(center, id), { headers: { accept: "application/json" } });
276
+ const metaRes = await fetch(skinUrl(center, id), {
277
+ headers: { accept: "application/json" },
278
+ signal: AbortSignal.any([clientAbort.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
279
+ });
198
280
  const meta = await metaRes.json().catch(() => null);
199
281
  const skin = meta?.skin;
200
282
  if (!metaRes.ok || !skin || skin.status && skin.status !== "approved") {
@@ -202,13 +284,19 @@ export function createGalleryHandlers({ dataRoot, actions }) {
202
284
  }
203
285
  await fsp.mkdir(tmpDir, { recursive: true });
204
286
  const imageUrl = skinUrl(center, id, "image");
205
- const imagePath = path.join(tmpDir, `image${extensionOf(imageUrl, ".png")}`);
287
+ const imageTmp = path.join(tmpDir, "image.download");
206
288
  write({ phase: "download", part: "image" });
207
- await downloadToFile(imageUrl, imagePath, {
289
+ const imageDownload = await downloadToFile(imageUrl, imageTmp, {
208
290
  maxBytes: MAX_IMAGE_BYTES,
209
291
  expectedOrigin: origin,
292
+ signal: clientAbort.signal,
210
293
  onProgress: (done, total) => write({ phase: "download", part: "image", done, total }),
211
294
  });
295
+ const imagePath = path.join(
296
+ tmpDir,
297
+ `image${extensionForContentType(imageDownload.contentType) ?? extensionOf(imageUrl, ".png")}`,
298
+ );
299
+ await fsp.rename(imageTmp, imagePath);
212
300
  let videoPath;
213
301
  if (skin.type === "video") {
214
302
  const videoUrl = skinUrl(center, id, "video");
@@ -217,25 +305,34 @@ export function createGalleryHandlers({ dataRoot, actions }) {
217
305
  await downloadToFile(videoUrl, videoPath, {
218
306
  maxBytes: MAX_VIDEO_BYTES,
219
307
  expectedOrigin: origin,
308
+ signal: clientAbort.signal,
220
309
  onProgress: (done, total) => write({ phase: "download", part: "video", done, total }),
221
310
  });
222
311
  }
223
- write({ phase: "import" });
312
+ // Skin-center media is downloaded into the store, so it is a managed
313
+ // import. importTheme applies-and-saves atomically; the media is copied
314
+ // before it returns, so tmpDir is safe to remove in finally.
315
+ write({ phase: "apply" });
316
+ // The apply/save step is atomic and cannot be interrupted; refuse to
317
+ // start it when the client already went away so a disconnected UI does
318
+ // not still commit and apply a background after the controls recovered.
319
+ if (clientAbort.signal.aborted) throw new Error("客户端已断开。");
224
320
  const imported = await importTheme({
225
- name: String(skin.name || id).slice(0, 80),
321
+ name: normalizeThemeName(skin.name || id),
226
322
  imagePath,
227
323
  ...(videoPath ? { videoPath } : {}),
228
324
  ...(skin.effects ? { effects: skin.effects } : {}),
229
- source: { kind: "skin-center", skinId: id, centerUrl: center },
325
+ source: "managed",
230
326
  });
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(() => {});
327
+ fetch(skinUrl(center, id, "download"), {
328
+ method: "POST",
329
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
330
+ }).catch(() => {});
234
331
  write({
235
332
  ok: true,
236
333
  phase: "done",
237
334
  theme: imported.theme || imported,
238
- message: applied.message || `已安装并应用「${skin.name}」。`,
335
+ message: imported.message || `已安装并应用「${skin.name}」。`,
239
336
  });
240
337
  } catch (error) {
241
338
  write({
@@ -243,6 +340,7 @@ export function createGalleryHandlers({ dataRoot, actions }) {
243
340
  error: error instanceof Error ? error.message : String(error),
244
341
  });
245
342
  } finally {
343
+ res.off("close", onClose);
246
344
  await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
247
345
  res.end();
248
346
  }
package/gallery.js CHANGED
@@ -48,6 +48,10 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
48
48
  let centerUrl = "";
49
49
  let busy = false;
50
50
 
51
+ // Mirrors gallery-host.mjs::isSafeSkinId — catalog ids are only safe to
52
+ // render when they match the skin-<hex> shape the backend already validates.
53
+ const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
54
+
51
55
  function escapeText(value) {
52
56
  return String(value ?? "")
53
57
  .replaceAll("&", "&amp;")
@@ -55,6 +59,12 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
55
59
  .replaceAll(">", "&gt;");
56
60
  }
57
61
 
62
+ // Attribute context additionally needs quote escaping; escapeText alone lets
63
+ // a malicious catalog id break out of data-id/src and inject handlers.
64
+ function escapeAttr(value) {
65
+ return escapeText(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
66
+ }
67
+
58
68
  async function request(path, init) {
59
69
  const response = await fetch(path, init);
60
70
  if ((response.headers.get("content-type") || "").includes("ndjson")) {
@@ -98,36 +108,48 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
98
108
 
99
109
  async function load() {
100
110
  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 里填入你的域名。";
111
+ try {
112
+ const params = new URLSearchParams();
113
+ if (queryInput.value.trim()) params.set("q", queryInput.value.trim());
114
+ if (typeSelect.value) params.set("type", typeSelect.value);
115
+ const data = await request(`/__beauticode/ui/gallery/catalog?${params}`);
116
+ centerUrl = data.url || centerUrl;
117
+ grid.innerHTML = (data.skins || [])
118
+ .filter((skin) => SKIN_ID.test(String(skin.id ?? "")))
119
+ .map(
120
+ (skin) =>
121
+ `<button type="button" class="bcg-card" data-id="${escapeAttr(skin.id)}">` +
122
+ `<img alt="" src="${escapeAttr(centerUrl)}/api/skins/${escapeAttr(skin.id)}/card">` +
123
+ `<span>${escapeText(skin.name)}${skin.type === "video" ? " · 视频" : ""}</span></button>`,
124
+ )
125
+ .join("");
126
+ msg.textContent = data.skins?.length ? "" : "目录是空的。";
127
+ foot.innerHTML = centerUrl
128
+ ? `上传与审核在 <a href="${escapeAttr(centerUrl)}" target="_blank" rel="noreferrer">皮肤中心网站</a>。安装会下载到本机后再应用。`
129
+ : "未配置皮肤中心地址。在插件的 skin-center.json 或环境变量 BEAUTICODE_SKIN_CENTER 里填入你的域名。";
130
+ } catch (error) {
131
+ grid.innerHTML = "";
132
+ msg.textContent = error instanceof Error ? error.message : String(error);
133
+ }
118
134
  }
119
135
 
120
136
  async function open() {
121
137
  host.hidden = false;
122
- const config = await request("/__beauticode/ui/gallery/config");
123
- centerUrl = config.url || "";
124
- if (!config.enabled) {
138
+ try {
139
+ const config = await request("/__beauticode/ui/gallery/config");
140
+ centerUrl = config.url || "";
141
+ if (!config.enabled) {
142
+ grid.innerHTML = "";
143
+ msg.textContent = "尚未配置皮肤中心地址。";
144
+ foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。";
145
+ return;
146
+ }
147
+ await load();
148
+ } catch (error) {
125
149
  grid.innerHTML = "";
126
- msg.textContent = "尚未配置皮肤中心地址。";
127
- foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。";
128
- return;
150
+ msg.textContent = error instanceof Error ? error.message : String(error);
151
+ throw error;
129
152
  }
130
- await load();
131
153
  }
132
154
 
133
155
  function close() {
package/host-apply.mjs CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  } from "./control-client.mjs";
8
8
  import { canvasImagePath } from "./presets.mjs";
9
9
 
10
- const sessions = new Map();
11
- const DSH_VERIFY_DEADLINE_MS = 10_000;
10
+ const sessions = new Map();
11
+ const DSH_VERIFY_DEADLINE_MS = 10_000;
12
12
 
13
13
  export const ENGINE_MISSING_MESSAGE =
14
14
  "beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。";
@@ -93,7 +93,7 @@ export async function ensureInProcessSession(options) {
93
93
  const session = new adapter.DshSession({
94
94
  dataRoot,
95
95
  baseUrl,
96
- verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
96
+ verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
97
97
  bundledGalleryImagePath: canvasImagePath() || undefined,
98
98
  });
99
99
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beauticode-dsh",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -1,9 +1,9 @@
1
- # Internal / Infernal atmosphere assets
2
-
3
- `bg-internal.jpg`, `bg-infernal.jpg`, `bg-canvas.png`, and the local
4
- `bg-canvas-4k.png` (Real-ESRGAN 4x then 3840×2160) are visual materials
5
- from [Internal Beyond](https://github.com/Sui-IB/InternalBeyond) by Sui.
6
-
7
- They remain under that project's CC BY-NC-SA 4.0 terms for visual assets.
8
- BeautiCode ships them as bundled workspace themes for local, non-commercial use.
9
- The project name, logo, and character identity are not licensed here.
1
+ # Internal / Infernal atmosphere assets
2
+
3
+ `bg-internal.jpg`, `bg-infernal.jpg`, `bg-canvas.png`, and the local
4
+ `bg-canvas-4k.png` (Real-ESRGAN 4x then 3840×2160) are visual materials
5
+ from [Internal Beyond](https://github.com/Sui-IB/InternalBeyond) by Sui.
6
+
7
+ They remain under that project's CC BY-NC-SA 4.0 terms for visual assets.
8
+ BeautiCode ships them as bundled workspace themes for local, non-commercial use.
9
+ The project name, logo, and character identity are not licensed here.
package/ui-host.mjs CHANGED
@@ -20,6 +20,10 @@ const NATIVE_PICKER_UNAVAILABLE = "native_picker_unavailable";
20
20
  const NATIVE_PICKER_REQUIRED = "native_picker_required";
21
21
  const PICKER_REQUEST_ABORTED = "picker_request_aborted";
22
22
  const IMPORT_TIMING_LOG = "import-timing.jsonl";
23
+ // /reapply runs a full apply transaction (import + stage + verify + commit),
24
+ // which has no total deadline of its own; give it headroom beyond the 30s that
25
+ // the two-verify video path can already approach (~26s).
26
+ const REAPPLY_TIMEOUT_MS = 60_000;
23
27
 
24
28
  function elapsedMs(startedAt) {
25
29
  return Math.round((performance.now() - startedAt) * 10) / 10;
@@ -369,7 +373,7 @@ export function createBeauticodeUi({
369
373
  method: "POST",
370
374
  path: "/reapply",
371
375
  body: {},
372
- timeoutMs: 30_000,
376
+ timeoutMs: REAPPLY_TIMEOUT_MS,
373
377
  });
374
378
  }
375
379
  if (!(await canReachBridge(options.baseUrl))) return;
@@ -379,7 +383,7 @@ export function createBeauticodeUi({
379
383
  method: "POST",
380
384
  path: "/reapply",
381
385
  body: {},
382
- timeoutMs: 30_000,
386
+ timeoutMs: REAPPLY_TIMEOUT_MS,
383
387
  });
384
388
  }
385
389
  return resolved.session.reapply();
@@ -145,7 +145,33 @@ export class DshSession {
145
145
  const result = await tx.run(input, themeName
146
146
  ? {
147
147
  beforeFinalize: async () => {
148
- saved.theme = await this.store.saveCurrentTheme(themeName);
148
+ // Persist the initial seek so a restore before the periodic
149
+ // progress writer runs still resumes at the requested position.
150
+ let videoPositionSec = null;
151
+ if (input.type === "video") {
152
+ if (this.host) {
153
+ try {
154
+ const position = await this.host.getPlaybackPosition();
155
+ if (position.ok &&
156
+ position.hasVideo &&
157
+ Number.isFinite(position.currentTime)) {
158
+ videoPositionSec = position.currentTime;
159
+ }
160
+ }
161
+ catch {
162
+ /* fall back to input.startAt below */
163
+ }
164
+ }
165
+ const startAt = typeof input.startAt === "number" && Number.isFinite(input.startAt)
166
+ ? input.startAt
167
+ : null;
168
+ if (videoPositionSec == null && startAt != null) {
169
+ videoPositionSec = Math.max(0, startAt);
170
+ }
171
+ }
172
+ saved.theme = await this.store.saveCurrentTheme(themeName, {
173
+ ...(videoPositionSec != null ? { videoPositionSec } : {}),
174
+ });
149
175
  },
150
176
  onRollback: async () => {
151
177
  if (saved.theme) {
@@ -155,7 +181,10 @@ export class DshSession {
155
181
  }
156
182
  : undefined);
157
183
  if (result.ok) {
158
- this.activeThemeId = saved.theme?.type === "video" ? saved.theme.id : null;
184
+ // Retain the saved theme id for image themes too: status.themeId marks
185
+ // the current selection in the console, and video-position tracking is
186
+ // already gated on hasVideo / "Not a video theme" downstream.
187
+ this.activeThemeId = saved.theme?.id ?? null;
159
188
  this.lastProgressWriteSec = -1;
160
189
  if (input.type === "clear") {
161
190
  this.fishMode = false;
@@ -297,6 +326,7 @@ export class DshSession {
297
326
  fish: this.fishMode,
298
327
  muted: this.videoMuted,
299
328
  tone: this.backgroundTone,
329
+ themeId: this.activeThemeId,
300
330
  };
301
331
  }
302
332
  async saveCurrentTheme(name) {
@@ -313,13 +343,8 @@ export class DshSession {
313
343
  }
314
344
  }
315
345
  const theme = await this.store.saveCurrentTheme(name, { videoPositionSec });
316
- if (theme.type === "video") {
317
- this.activeThemeId = theme.id;
318
- this.lastProgressWriteSec = -1;
319
- }
320
- else {
321
- this.activeThemeId = null;
322
- }
346
+ this.activeThemeId = theme.id;
347
+ this.lastProgressWriteSec = -1;
323
348
  return theme;
324
349
  }
325
350
  async listSavedThemes() {
@@ -338,7 +363,7 @@ export class DshSession {
338
363
  const saved = await this.store.loadSavedTheme(themeId);
339
364
  const result = await this.apply(saved.input);
340
365
  if (result.ok) {
341
- this.activeThemeId = saved.input.type === "video" ? saved.themeId : null;
366
+ this.activeThemeId = saved.themeId;
342
367
  this.lastProgressWriteSec = -1;
343
368
  }
344
369
  return result;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { MAX_INLINE_DATA_URL_BYTES } from "./constants.js";
3
+ import { MAX_INLINE_DATA_URL_BYTES, MAX_VIDEO_POSTER_INLINE_BYTES, } from "./constants.js";
4
4
  import { isLocalBackgroundSource, resolveBackgroundImagePath, resolveBackgroundVideoPath, } from "./media-source.js";
5
5
  import { detectImageMime } from "./media-validation.js";
6
6
  // Cold-first-access symptoms reported by the renderer while the OS is still
@@ -286,7 +286,13 @@ export async function buildHostApplyPayload(store, manifest, staged, cssText, vi
286
286
  const imagePath = resolveBackgroundImagePath(store.paths.activeDir, manifest.background);
287
287
  if (!imagePath)
288
288
  throw new Error("Background has no image source.");
289
- const imageDataUrl = opts.includeImageDataUrl === false ? null : await fileToDataUrl(imagePath);
289
+ const imageStat = await fs.stat(imagePath).catch(() => null);
290
+ const skipInlinePoster = manifest.background.type === "video" &&
291
+ imageStat != null &&
292
+ imageStat.size > MAX_VIDEO_POSTER_INLINE_BYTES;
293
+ const imageDataUrl = opts.includeImageDataUrl === false || skipInlinePoster
294
+ ? null
295
+ : await fileToDataUrl(imagePath);
290
296
  const imageUrl = staged?.image?.srcUrl ?? null;
291
297
  if (manifest.background.type === "image") {
292
298
  return {
@@ -9,6 +9,11 @@ export const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
9
9
  * Keep this equal to MAX_IMAGE_BYTES so every accepted image can publish.
10
10
  */
11
11
  export const MAX_INLINE_DATA_URL_BYTES = MAX_IMAGE_BYTES;
12
+ /**
13
+ * Keep a large video poster out of the Codex Runtime.evaluate payload.
14
+ * The first decoded video frame replaces the poster after the blob attaches.
15
+ */
16
+ export const MAX_VIDEO_POSTER_INLINE_BYTES = 1024 * 1024;
12
17
  export const COMMIT_MARKER_NAME = ".beauticode-commit-in-progress";
13
18
  export const COMMIT_MARKER_STALE_MS = 120_000;
14
19
  export const MEDIA_TOKEN_HEADER = "x-beauticode-media-token";
@@ -10,4 +10,5 @@ export * from "./error-message.js";
10
10
  export * from "./background-store.js";
11
11
  export * from "./bundled-gallery.js";
12
12
  export * from "./apply-transaction.js";
13
+ export * from "./process-liveness.js";
13
14
  //# sourceMappingURL=index.js.map
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
+ import { assertSafeBasename } from "./media-validation.js";
2
3
  /** Resolve a manifest source against the directory that owns managed files. */
3
4
  export function resolveMediaSource(source, ownerDir) {
4
5
  if (source.kind === "local")
5
6
  return path.resolve(source.path);
7
+ assertSafeBasename(source.file, "background.source.file");
6
8
  return path.join(ownerDir, source.file);
7
9
  }
8
10
  /** Resolve the primary image source, including legacy v1 manifests. */
@@ -0,0 +1,81 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * How close a process's actual start time must be to the recorded start time
4
+ * before we treat them as the same process. PID reuse yields a completely
5
+ * different start time, so a generous window is safe while still rejecting
6
+ * recycled PIDs.
7
+ */
8
+ const START_TIME_TOLERANCE_MS = 30_000;
9
+ /**
10
+ * True when `pid` refers to a process that still exists. Mirrors the PID check
11
+ * callers perform before consulting a recorded process identity.
12
+ */
13
+ function isPidAlive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return (Boolean(error) &&
20
+ typeof error === "object" &&
21
+ error.code === "EPERM");
22
+ }
23
+ }
24
+ /**
25
+ * Resolve the actual process start time as an epoch millisecond value.
26
+ * Returns null when the process does not exist or the platform helper fails.
27
+ */
28
+ function processStartTimeMs(pid) {
29
+ return new Promise((resolve) => {
30
+ if (process.platform === "win32") {
31
+ const script = `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`;
32
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3_000, windowsHide: true }, (error, stdout) => {
33
+ if (error)
34
+ return resolve(null);
35
+ const value = Date.parse(String(stdout).trim());
36
+ resolve(Number.isFinite(value) ? value : null);
37
+ });
38
+ return;
39
+ }
40
+ // Linux / macOS: ps -o lstart= prints the start time in local time with no
41
+ // timezone suffix, which Date.parse interprets as the host's local time.
42
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], { timeout: 3_000 }, (error, stdout) => {
43
+ if (error)
44
+ return resolve(null);
45
+ const value = Date.parse(String(stdout).trim());
46
+ resolve(Number.isFinite(value) ? value : null);
47
+ });
48
+ });
49
+ }
50
+ /**
51
+ * True when `pid` is alive AND its real start time matches `recordedIso` (the
52
+ * startedAt value a control/claim file captured when the process launched).
53
+ *
54
+ * Falls back to optimistic `true` when there is no recorded timestamp or the
55
+ * platform helper is unavailable, so an unverifiable check never marks a live
56
+ * tray/session as dead. Returns `false` when the PID no longer exists or its
57
+ * start time clearly differs (PID reuse).
58
+ */
59
+ export async function isRecordedPidLive(pid, recordedIso) {
60
+ if (!Number.isInteger(pid) || pid <= 0)
61
+ return false;
62
+ if (!isPidAlive(pid))
63
+ return false;
64
+ if (typeof recordedIso !== "string" || !recordedIso)
65
+ return true;
66
+ const recordedMs = Date.parse(recordedIso);
67
+ if (!Number.isFinite(recordedMs))
68
+ return true;
69
+ let actualMs;
70
+ try {
71
+ actualMs = await processStartTimeMs(pid);
72
+ }
73
+ catch {
74
+ actualMs = null;
75
+ }
76
+ // No helper result means "cannot verify" — keep the optimistic PID check.
77
+ if (actualMs == null)
78
+ return true;
79
+ return Math.abs(actualMs - recordedMs) <= START_TIME_TOLERANCE_MS;
80
+ }
81
+ //# sourceMappingURL=process-liveness.js.map