beauticode-dsh 1.0.13 → 1.0.14

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/client.js CHANGED
@@ -7,9 +7,10 @@
7
7
  const clientId =
8
8
  globalThis.crypto?.randomUUID?.() ||
9
9
  `bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
10
- const desiredModes = { fish: false, muted: true, tone: "auto" };
11
- let activePayload = null;
12
- let playbackBlocked = false;
10
+ const desiredModes = { fish: false, muted: true, tone: "auto" };
11
+ let activePayload = null;
12
+ let renderPhase = "idle";
13
+ let playbackBlocked = false;
13
14
  const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
14
15
  let themeSyncQueued = false;
15
16
 
@@ -160,8 +161,11 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
160
161
  };
161
162
  }
162
163
 
163
- async function acknowledgeRender(payload, ok, visible, error = null) {
164
- await postAck({
164
+ async function acknowledgeRender(payload, ok, visible, error = null) {
165
+ if (activePayload === payload) {
166
+ renderPhase = ok ? "ready" : "failed";
167
+ }
168
+ await postAck({
165
169
  kind: "render",
166
170
  generation: payload.generation,
167
171
  media: payload.media,
@@ -329,9 +333,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
329
333
  globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
330
334
  }
331
335
 
332
- async function applyBackground(payload) {
333
- if (!Number.isSafeInteger(payload?.generation)) return;
334
- activePayload = payload;
336
+ async function applyBackground(payload) {
337
+ if (!Number.isSafeInteger(payload?.generation)) return;
338
+ activePayload = payload;
339
+ renderPhase = "pending";
335
340
  document.documentElement.dataset.bcGeneration = String(payload.generation);
336
341
  syncGallery(payload);
337
342
  if (payload.media === "clear") {
@@ -417,9 +422,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
417
422
  } catch {
418
423
  playbackBlocked = desiredModes.muted === false;
419
424
  }
420
- if (activePayload?.media === "video") {
421
- await acknowledgeRender(activePayload, video.readyState >= 2 && !video.paused, true);
422
- }
425
+ if (activePayload?.media === "video" && renderPhase === "ready") {
426
+ await acknowledgeRender(activePayload, true, true);
427
+ }
423
428
  } else {
424
429
  playbackBlocked = false;
425
430
  }
@@ -441,8 +446,15 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
441
446
 
442
447
  setInterval(() => {
443
448
  const video = activeVideo();
444
- if (activePayload?.media === "video" && video instanceof HTMLVideoElement) {
445
- void acknowledgeRender(activePayload, video.readyState >= 2 && !video.paused, true);
446
- }
449
+ if (
450
+ activePayload?.media === "video" &&
451
+ renderPhase === "ready" &&
452
+ video instanceof HTMLVideoElement
453
+ ) {
454
+ // A heartbeat is observational, not a second render verdict. Playback can
455
+ // briefly pause while Chromium changes modes or refills an 8K buffer; do
456
+ // not downgrade an already-rendered generation or fail a pending one.
457
+ void acknowledgeRender(activePayload, true, true);
458
+ }
447
459
  }, 1_000);
448
460
  })();
package/console.js CHANGED
@@ -16,44 +16,48 @@
16
16
  #beauticode-console.rail .bc-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
17
17
  #beauticode-console .bc-icon{flex:none;display:block}
18
18
  #beauticode-console .bc-label{white-space:nowrap;overflow:hidden}
19
- #beauticode-console-pop{position:fixed;z-index:2000;box-sizing:border-box;width:220px;padding:10px;border:1px solid rgba(255,255,255,.08);border-radius:16px;background:#2c323c;color:#e8eaed;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.28));font:inherit;font-size:13px}
20
- #beauticode-console-pop *{box-sizing:border-box;font-family:inherit}
21
- body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.08)}
22
- #beauticode-console-pop .bc-status{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:18px;margin:0 0 8px}
23
- #beauticode-console-pop .bc-row{display:flex;gap:6px;margin:0 0 6px}
24
- #beauticode-console-pop .bc-btn,#beauticode-console-pop .bc-theme-toggle{cursor:pointer;flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid rgba(255,255,255,.1);border-radius:12px;background:rgba(255,255,255,.06);color:inherit;font-size:13px;line-height:20px}
25
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-btn,
26
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle{border-color:rgba(0,0,0,.08);background:#f4f6f8}
27
- #beauticode-console-pop .bc-btn:hover,#beauticode-console-pop .bc-theme-toggle:hover{background:rgba(255,255,255,.12)}
28
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-btn:hover,
29
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle:hover{background:#eceff3}
30
- #beauticode-console-pop .bc-btn:disabled,#beauticode-console-pop .bc-theme-toggle:disabled,#beauticode-console-pop .bc-theme-item:disabled{opacity:.45;cursor:default}
31
- #beauticode-console-pop .bc-btn.on{background:rgba(255,255,255,.16)}
32
- #beauticode-console-pop .bc-theme-toggle{width:100%;text-align:left}
33
- #beauticode-console-pop .bc-theme-list{margin:6px 0 0;max-height:160px;overflow:auto;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:#232830;padding:4px}
34
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-list{border-color:rgba(0,0,0,.08);background:#f4f6f8}
35
- #beauticode-console-pop .bc-theme-row{display:flex;gap:4px;align-items:center}
36
- #beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;flex:1;min-width:0;height:32px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:inherit;font-size:13px;line-height:32px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
37
- #beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)}
38
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)}
39
- #beauticode-console-pop .bc-source{margin-left:6px;font-size:11px;opacity:.62}
40
- #beauticode-console-pop .bc-theme-del{cursor:pointer;flex:none;width:32px;height:32px;padding:0;border:none;border-radius:8px;background:transparent;color:inherit;font-size:12px;opacity:.72}
41
- #beauticode-console-pop .bc-theme-del:hover{background:rgba(255,255,255,.08);opacity:1}
42
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{background:rgba(0,0,0,.06)}
43
- #beauticode-console-pop .bc-msg{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:16px;margin:6px 0 0;max-height:3.2em;overflow:hidden}
19
+ #beauticode-console-pop{position:fixed;z-index:2000;box-sizing:border-box;width:256px;padding:0 14px 12px;border:1px solid rgba(23,26,29,.24);border-top:4px solid #252a30;border-radius:2px;background:#ece9e2;color:#202327;box-shadow:0 16px 36px rgba(0,0,0,.34);font:inherit;font-size:13px}
20
+ #beauticode-console-pop *{box-sizing:border-box;font-family:inherit}
21
+ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:#202327;border-color:rgba(23,26,29,.22)}
22
+ #beauticode-console-pop .bc-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;padding:12px 0 10px;border-bottom:1px solid #b9b6af}
23
+ #beauticode-console-pop .bc-title{margin:0;font:650 16px/20px Georgia,"Songti SC","STSong",serif;letter-spacing:.02em}
24
+ #beauticode-console-pop .bc-status{min-width:0;max-width:142px;color:#686d71;font:10px/15px ui-monospace,"Cascadia Mono",monospace;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
25
+ #beauticode-console-pop .bc-import{cursor:pointer;display:grid;grid-template-columns:34px minmax(0,1fr) 18px;align-items:center;width:100%;min-height:49px;padding:0;border:0;border-bottom:1px solid #c8c5be;background:transparent;color:inherit;text-align:left}
26
+ #beauticode-console-pop .bc-import:hover{background:rgba(32,35,39,.05)}
27
+ #beauticode-console-pop .bc-index{color:#74787b;font:10px ui-monospace,"Cascadia Mono",monospace}
28
+ #beauticode-console-pop .bc-import-copy{min-width:0}
29
+ #beauticode-console-pop .bc-import strong{display:block;font-size:13px;line-height:17px;font-weight:650}
30
+ #beauticode-console-pop .bc-import small{display:block;color:#717579;font-size:10px;line-height:14px}
31
+ #beauticode-console-pop .bc-arrow{font-size:16px;text-align:right}
32
+ #beauticode-console-pop .bc-controls{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid #c8c5be}
33
+ #beauticode-console-pop .bc-link{cursor:pointer;height:auto;padding:0;border:0;background:transparent;color:#595e62;font-size:11px;line-height:18px;text-decoration:underline;text-underline-offset:3px}
34
+ #beauticode-console-pop .bc-link:hover{color:#171a1d}
35
+ #beauticode-console-pop .bc-theme-toggle{cursor:pointer;display:flex;align-items:center;justify-content:space-between;width:100%;height:31px;padding:8px 0 4px;border:0;background:transparent;color:#686d71;font:10px ui-monospace,"Cascadia Mono",monospace;letter-spacing:.08em;text-align:left}
36
+ #beauticode-console-pop .bc-theme-list{max-height:150px;overflow:auto;scrollbar-width:thin}
37
+ #beauticode-console-pop .bc-theme-row{display:flex;align-items:center;min-width:0;border-bottom:1px dotted #bbb8b1}
38
+ #beauticode-console-pop .bc-theme-row:last-child{border-bottom:0}
39
+ #beauticode-console-pop .bc-theme-item{cursor:pointer;display:flex;align-items:center;flex:1;min-width:0;height:31px;padding:0;border:0;background:transparent;color:inherit;font-size:12px;line-height:31px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
40
+ #beauticode-console-pop .bc-theme-item::before{content:"";flex:none;width:16px;font-size:9px}
41
+ #beauticode-console-pop .bc-theme-item[aria-current="true"]{font-weight:650}
42
+ #beauticode-console-pop .bc-theme-item[aria-current="true"]::before{content:"●"}
43
+ #beauticode-console-pop .bc-theme-item:hover{background:rgba(32,35,39,.05)}
44
+ #beauticode-console-pop .bc-source{margin-left:auto;padding-left:6px;color:#777b7e;font:9px ui-monospace,"Cascadia Mono",monospace}
45
+ #beauticode-console-pop .bc-theme-del{cursor:pointer;flex:none;width:22px;height:28px;padding:0;border:0;background:transparent;color:#777b7e;font-size:15px;line-height:28px;opacity:.66}
46
+ #beauticode-console-pop .bc-theme-del:hover{color:#802f2f;opacity:1}
47
+ #beauticode-console-pop .bc-btn:disabled,#beauticode-console-pop .bc-theme-toggle:disabled,#beauticode-console-pop .bc-theme-item:disabled,#beauticode-console-pop .bc-theme-del:disabled{opacity:.38;cursor:default}
48
+ #beauticode-console-pop[data-busy="true"] .bc-head::before{content:"";width:5px;height:5px;margin:0 0 6px;background:#6d7f8c;animation:bc-pulse .9s steps(2,end) infinite}
49
+ #beauticode-console-pop .bc-msg{margin:9px 0 0;color:#656a6e;font-size:11px;line-height:16px;max-height:3.2em;overflow:hidden}
50
+ @keyframes bc-pulse{50%{opacity:.25}}
44
51
  #beauticode-console-file{display:none !important}
45
52
  #beauticode-name-dialog{position:fixed;inset:0;z-index:3000;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);font:inherit}
46
- #beauticode-name-dialog .bc-name-card{display:flex;flex-direction:column;gap:10px;width:min(380px,calc(100vw - 48px));padding:18px;border:1px solid rgba(255,255,255,.12);border-radius:18px;background:#2c323c;color:#e8eaed;box-shadow:0 18px 60px rgba(0,0,0,.35)}
47
- #beauticode-name-dialog .bc-name-title{margin:0;font-size:15px;font-weight:600}
48
- #beauticode-name-dialog .bc-name-file,#beauticode-name-dialog .bc-name-note,#beauticode-name-dialog .bc-name-error{margin:0;color:#aeb7c3;font-size:12px;line-height:18px;overflow-wrap:anywhere}
49
- #beauticode-name-dialog .bc-name-error{color:#ff9a9a}
50
- #beauticode-name-dialog input{height:36px;padding:0 10px;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(255,255,255,.08);color:inherit;font:inherit;font-size:13px}
53
+ #beauticode-name-dialog .bc-name-card{display:flex;flex-direction:column;gap:10px;width:min(380px,calc(100vw - 48px));padding:20px;border:1px solid #aaa69e;border-top:4px solid #252a30;border-radius:2px;background:#ece9e2;color:#202327;box-shadow:0 18px 60px rgba(0,0,0,.35)}
54
+ #beauticode-name-dialog .bc-name-title{margin:0;font:650 17px Georgia,"Songti SC","STSong",serif}
55
+ #beauticode-name-dialog .bc-name-file,#beauticode-name-dialog .bc-name-note,#beauticode-name-dialog .bc-name-error{margin:0;color:#666b6f;font-size:12px;line-height:18px;overflow-wrap:anywhere}
56
+ #beauticode-name-dialog .bc-name-error{color:#8d3030}
57
+ #beauticode-name-dialog input{height:38px;padding:0 10px;border:1px solid #a8a49c;border-radius:0;background:#f7f4ed;color:inherit;font:inherit;font-size:13px}
51
58
  #beauticode-name-dialog .bc-name-actions{display:flex;justify-content:flex-end;gap:8px}
52
- #beauticode-name-dialog button{cursor:pointer;height:34px;padding:0 13px;border:1px solid rgba(255,255,255,.12);border-radius:10px;background:rgba(255,255,255,.08);color:inherit;font:inherit}
53
- #beauticode-name-dialog button[data-name="confirm"]{background:#4f7cff;border-color:#4f7cff;color:#fff}
54
- body:not([data-ds-dark-theme]) #beauticode-name-dialog .bc-name-card{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.1)}
55
- body:not([data-ds-dark-theme]) #beauticode-name-dialog input,body:not([data-ds-dark-theme]) #beauticode-name-dialog button{border-color:rgba(0,0,0,.12);background:#f4f6f8}
56
- body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm"]{background:#315efb;border-color:#315efb;color:#fff}
59
+ #beauticode-name-dialog button{cursor:pointer;height:34px;padding:0 13px;border:1px solid #8d8a84;border-radius:0;background:transparent;color:inherit;font:inherit}
60
+ #beauticode-name-dialog button[data-name="confirm"]{background:#252a30;border-color:#252a30;color:#f7f4ed}
57
61
  `;
58
62
  document.head.append(style);
59
63
 
@@ -69,24 +73,20 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
69
73
  '<span class="bc-label">背景</span></button>';
70
74
 
71
75
  const pop = document.createElement("div");
72
- pop.id = "beauticode-console-pop";
73
- pop.hidden = true;
74
- pop.innerHTML =
75
- '<p class="bc-status">未就绪</p>' +
76
- '<div class="bc-row">' +
77
- '<button type="button" class="bc-btn" data-act="image">图片</button>' +
78
- '<button type="button" class="bc-btn" data-act="video">视频</button>' +
79
- "</div>" +
80
- '<div class="bc-row">' +
81
- '<button type="button" class="bc-btn" data-act="clear">清除</button>' +
82
- '<button type="button" class="bc-btn" data-act="sound">声音</button>' +
83
- "</div>" +
84
- '<div class="bc-row">' +
85
- '<button type="button" class="bc-btn" data-act="gallery">皮肤中心</button>' +
86
- "</div>" +
87
- '<div class="bc-themes" hidden>' +
88
- '<button type="button" class="bc-theme-toggle" aria-expanded="false">已保存主题</button>' +
89
- '<div class="bc-theme-list" hidden></div>' +
76
+ pop.id = "beauticode-console-pop";
77
+ pop.hidden = true;
78
+ pop.innerHTML =
79
+ '<header class="bc-head"><h2 class="bc-title">背景清单</h2><span class="bc-status">未就绪</span></header>' +
80
+ '<button type="button" class="bc-btn bc-import" data-act="image"><span class="bc-index">01</span><span class="bc-import-copy"><strong>导入图片</strong><small>直接引用本地文件</small></span><span class="bc-arrow">→</span></button>' +
81
+ '<button type="button" class="bc-btn bc-import" data-act="video"><span class="bc-index">02</span><span class="bc-import-copy"><strong>导入视频</strong><small>MP4 · 零复制播放</small></span><span class="bc-arrow">→</span></button>' +
82
+ '<div class="bc-controls">' +
83
+ '<button type="button" class="bc-btn bc-link" data-act="sound">声音已关</button>' +
84
+ '<button type="button" class="bc-btn bc-link" data-act="clear">清除背景</button>' +
85
+ '<button type="button" class="bc-btn bc-link" data-act="gallery">打开皮肤中心</button>' +
86
+ "</div>" +
87
+ '<div class="bc-themes" hidden>' +
88
+ '<button type="button" class="bc-theme-toggle" aria-expanded="true"><span>SAVED / 00</span><span>−</span></button>' +
89
+ '<div class="bc-theme-list" hidden></div>' +
90
90
  "</div>" +
91
91
  '<p class="bc-msg" hidden></p>';
92
92
 
@@ -103,9 +103,10 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
103
103
  const themeToggle = pop.querySelector(".bc-theme-toggle");
104
104
  const themeList = pop.querySelector(".bc-theme-list");
105
105
  const msgEl = pop.querySelector(".bc-msg");
106
- let busy = false;
107
- let muted = true;
108
- let currentThemeId = "";
106
+ let busy = false;
107
+ let muted = true;
108
+ let currentThemeId = "";
109
+ let themesExpanded = true;
109
110
 
110
111
  function findSettingsTrigger() {
111
112
  const buttons = [...document.querySelectorAll('button[aria-haspopup="dialog"]')];
@@ -176,17 +177,22 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
176
177
  : data.sourceMode === "managed"
177
178
  ? "托管副本"
178
179
  : "";
179
- statusEl.textContent = sourceLabel ? `${label} · ${sourceLabel}` : label;
180
- if (typeof data.themeId === "string" && data.themeId) {
181
- currentThemeId = data.themeId;
182
- } else if (data.atmosphere === "gallery") {
183
- currentThemeId = "builtin-gallery";
184
- }
185
- muted = data.muted !== false;
186
- soundBtn.classList.toggle("on", !muted);
187
- soundBtn.textContent = muted ? "声音" : "声音开";
188
- const themes = Array.isArray(data.themes) ? data.themes : [];
189
- if (themes.length === 0) {
180
+ if (typeof data.themeId === "string" && data.themeId) {
181
+ currentThemeId = data.themeId;
182
+ } else if (data.atmosphere === "gallery") {
183
+ currentThemeId = "builtin-gallery";
184
+ } else {
185
+ currentThemeId = "";
186
+ }
187
+ muted = data.muted !== false;
188
+ soundBtn.classList.toggle("on", !muted);
189
+ soundBtn.textContent = muted ? "声音已关" : "声音已开";
190
+ const themes = Array.isArray(data.themes) ? data.themes : [];
191
+ const selected = themes.find((theme) => theme.id === currentThemeId);
192
+ const currentLabel = selected?.name || label;
193
+ const compactSource = sourceLabel === "本地引用" ? "本地" : sourceLabel === "托管副本" ? "托管" : "已应用";
194
+ statusEl.textContent = `${currentLabel} / ${compactSource}`;
195
+ if (themes.length === 0) {
190
196
  themesBox.hidden = true;
191
197
  themeList.innerHTML = "";
192
198
  themeList.hidden = true;
@@ -196,17 +202,19 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
196
202
  themesBox.hidden = false;
197
203
  themeList.innerHTML = themes
198
204
  .map((theme) => {
199
- const del =
200
- theme.bundled === true
201
- ? ""
202
- : `<button type="button" class="bc-theme-del" data-theme-delete="${escapeAttr(theme.id)}" data-theme-name="${escapeAttr(theme.name)}" aria-label="删除 ${escapeAttr(theme.name)}">删</button>`;
205
+ const del =
206
+ theme.bundled === true
207
+ ? ""
208
+ : `<button type="button" class="bc-theme-del" data-theme-delete="${escapeAttr(theme.id)}" data-theme-name="${escapeAttr(theme.name)}" aria-label="删除 ${escapeAttr(theme.name)}">×</button>`;
203
209
  const source =
204
210
  theme.sourceMode === "local" ? "本地" : theme.bundled ? "内置" : "托管";
205
- return `<div class="bc-theme-row"><button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}">${escapeText(theme.name)}<span class="bc-source">${source}</span></button>${del}</div>`;
206
- })
207
- .join("");
208
- const selected = themes.find((theme) => theme.id === currentThemeId);
209
- themeToggle.textContent = selected ? selected.name : "已保存主题";
211
+ const current = theme.id === currentThemeId ? ' aria-current="true"' : "";
212
+ return `<div class="bc-theme-row"><button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}"${current}><span>${escapeText(theme.name)}</span><span class="bc-source">${source}</span></button>${del}</div>`;
213
+ })
214
+ .join("");
215
+ themeToggle.innerHTML = `<span>SAVED / ${String(themes.length).padStart(2, "0")}</span><span>${themesExpanded ? "−" : "+"}</span>`;
216
+ themeList.hidden = !themesExpanded;
217
+ themeToggle.setAttribute("aria-expanded", themesExpanded ? "true" : "false");
210
218
  }
211
219
 
212
220
  function escapeText(value) {
@@ -220,21 +228,36 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
220
228
  return escapeText(value).replaceAll('"', "&quot;");
221
229
  }
222
230
 
223
- async function request(path, init) {
224
- const response = await fetch(path, {
225
- ...init,
226
- headers: {
227
- ...(init?.headers || {}),
228
- },
229
- });
230
- const body = await response.json().catch(() => null);
231
- if (!response.ok || body?.ok === false) {
232
- const error = new Error(body?.error || `请求失败(${response.status})`);
233
- error.status = response.status;
234
- error.code = body?.code || "";
231
+ async function request(path, init, options = {}) {
232
+ const timeoutMs = options.timeoutMs === 0 ? 0 : options.timeoutMs || 45_000;
233
+ const controller = timeoutMs > 0 ? new AbortController() : null;
234
+ const timer = controller
235
+ ? setTimeout(() => controller.abort(new Error("background_request_timeout")), timeoutMs)
236
+ : null;
237
+ try {
238
+ const response = await fetch(path, {
239
+ ...init,
240
+ ...(controller ? { signal: controller.signal } : {}),
241
+ headers: {
242
+ ...(init?.headers || {}),
243
+ },
244
+ });
245
+ const body = await response.json().catch(() => null);
246
+ if (!response.ok || body?.ok === false) {
247
+ const error = new Error(body?.error || `请求失败(${response.status})`);
248
+ error.status = response.status;
249
+ error.code = body?.code || "";
250
+ throw error;
251
+ }
252
+ return body;
253
+ } catch (error) {
254
+ if (controller?.signal.aborted) {
255
+ throw new Error("背景操作超时,控件已恢复。原背景保持不变,请重试。");
256
+ }
235
257
  throw error;
236
- }
237
- return body;
258
+ } finally {
259
+ if (timer) clearTimeout(timer);
260
+ }
238
261
  }
239
262
 
240
263
  async function refresh() {
@@ -248,9 +271,10 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
248
271
  async function run(task) {
249
272
  if (busy) return;
250
273
  busy = true;
274
+ pop.dataset.busy = "true";
251
275
  let afterRun = null;
252
- for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = true;
253
- showMessage("");
276
+ for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = true;
277
+ showMessage("正在处理,请稍候…");
254
278
  try {
255
279
  const result = await task();
256
280
  if (typeof result?.afterRun === "function") afterRun = result.afterRun;
@@ -265,12 +289,15 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
265
289
  const totalMs = Number(result.importTimings?.applyAndSaveMs ?? result.timings?.totalMs);
266
290
  const duration = Number.isFinite(totalMs) ? `${Math.round(totalMs)} ms` : "";
267
291
  showMessage([result.message, source, duration].filter(Boolean).join(" · "));
292
+ } else {
293
+ showMessage("");
268
294
  }
269
295
  await refresh();
270
296
  } catch (error) {
271
297
  showMessage(error instanceof Error ? error.message : String(error));
272
298
  } finally {
273
299
  busy = false;
300
+ delete pop.dataset.busy;
274
301
  for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = false;
275
302
  }
276
303
  if (afterRun) queueMicrotask(afterRun);
@@ -351,11 +378,17 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
351
378
  async function pickAndImport(kind) {
352
379
  let picked;
353
380
  try {
354
- picked = await request("/__beauticode/ui/pick", {
355
- method: "POST",
356
- headers: { "content-type": "application/json" },
357
- body: JSON.stringify({ kind }),
358
- });
381
+ picked = await request(
382
+ "/__beauticode/ui/pick",
383
+ {
384
+ method: "POST",
385
+ headers: { "content-type": "application/json" },
386
+ body: JSON.stringify({ kind }),
387
+ },
388
+ // The user controls how long the native dialog stays open. Import and
389
+ // theme switching still use the bounded request timeout above.
390
+ { timeoutMs: 0 },
391
+ );
359
392
  } catch (error) {
360
393
  if (error?.code !== "native_picker_unavailable") throw error;
361
394
  return {
@@ -399,9 +432,12 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
399
432
  }
400
433
  showMessage("皮肤中心脚本尚未加载。");
401
434
  });
402
- pop.querySelector('[data-act="clear"]').addEventListener("click", () => {
403
- currentThemeId = "";
404
- void run(() => request("/__beauticode/ui/clear", { method: "POST" }));
435
+ pop.querySelector('[data-act="clear"]').addEventListener("click", () => {
436
+ void run(async () => {
437
+ const result = await request("/__beauticode/ui/clear", { method: "POST" });
438
+ currentThemeId = "";
439
+ return result;
440
+ });
405
441
  });
406
442
  soundBtn.addEventListener("click", () => {
407
443
  void run(() =>
@@ -412,11 +448,12 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
412
448
  }),
413
449
  );
414
450
  });
415
- themeToggle.addEventListener("click", (event) => {
416
- event.stopPropagation();
417
- const open = themeList.hidden;
418
- themeList.hidden = !open;
419
- themeToggle.setAttribute("aria-expanded", open ? "true" : "false");
451
+ themeToggle.addEventListener("click", (event) => {
452
+ event.stopPropagation();
453
+ themesExpanded = !themesExpanded;
454
+ themeList.hidden = !themesExpanded;
455
+ themeToggle.querySelector("span:last-child").textContent = themesExpanded ? "−" : "+";
456
+ themeToggle.setAttribute("aria-expanded", themesExpanded ? "true" : "false");
420
457
  });
421
458
  themeList.addEventListener("click", (event) => {
422
459
  const del = event.target.closest("[data-theme-delete]");
@@ -426,31 +463,34 @@ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm
426
463
  const name = del.getAttribute("data-theme-name") || "主题";
427
464
  if (!id) return;
428
465
  if (!window.confirm(`确定删除主题「${name}」?`)) return;
429
- if (currentThemeId === id) currentThemeId = "";
430
- void run(() =>
431
- request("/__beauticode/ui/theme/delete", {
432
- method: "POST",
433
- headers: { "content-type": "application/json" },
434
- body: JSON.stringify({ id }),
435
- }),
436
- );
437
- return;
438
- }
439
- const item = event.target.closest("[data-theme-id]");
440
- if (!item) return;
441
- currentThemeId = item.getAttribute("data-theme-id") || "";
442
- themeList.hidden = true;
443
- themeToggle.setAttribute("aria-expanded", "false");
444
- globalThis.BeauticodeAtmosphere?.setWindowMode?.(
445
- currentThemeId === "builtin-gallery" ? "on" : "closed",
446
- );
447
- void run(() =>
448
- request("/__beauticode/ui/theme/use", {
449
- method: "POST",
450
- headers: { "content-type": "application/json" },
451
- body: JSON.stringify({ id: currentThemeId }),
452
- }),
453
- );
466
+ void run(async () => {
467
+ const result = await request("/__beauticode/ui/theme/delete", {
468
+ method: "POST",
469
+ headers: { "content-type": "application/json" },
470
+ body: JSON.stringify({ id }),
471
+ });
472
+ if (currentThemeId === id) currentThemeId = "";
473
+ return result;
474
+ });
475
+ return;
476
+ }
477
+ const item = event.target.closest("[data-theme-id]");
478
+ if (!item) return;
479
+ const targetThemeId = item.getAttribute("data-theme-id") || "";
480
+ themeList.hidden = true;
481
+ themeToggle.setAttribute("aria-expanded", "false");
482
+ void run(async () => {
483
+ const result = await request("/__beauticode/ui/theme/use", {
484
+ method: "POST",
485
+ headers: { "content-type": "application/json" },
486
+ body: JSON.stringify({ id: targetThemeId }),
487
+ });
488
+ currentThemeId = targetThemeId;
489
+ globalThis.BeauticodeAtmosphere?.setWindowMode?.(
490
+ currentThemeId === "builtin-gallery" ? "on" : "closed",
491
+ );
492
+ return result;
493
+ });
454
494
  });
455
495
  fileInput.addEventListener("change", () => {
456
496
  const file = fileInput.files?.[0];
package/index.mjs CHANGED
@@ -168,7 +168,11 @@ function publicStatus(current, modes, clients, clientStates) {
168
168
  const playback =
169
169
  activeAcks.find((ack) => ack.ok === true && ack.playback?.hasVideo === true)
170
170
  ?.playback ?? null;
171
- const failedAcks = activeAcks.filter((ack) => ack.ok !== true);
171
+ // Only an explicit renderer error is terminal. Older clients may send a
172
+ // transient ok:false heartbeat while a large video is still warming up.
173
+ const failedAcks = activeAcks.filter(
174
+ (ack) => ack.ok === false && typeof ack.error === "string" && ack.error,
175
+ );
172
176
  return {
173
177
  ok: true,
174
178
  connectedClients: clients.size,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beauticode-dsh",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -119,11 +119,14 @@ export class DshHostApplier {
119
119
  details: { ...last },
120
120
  };
121
121
  }
122
- if (currentMatches && last.failedClients > 0 && last.readyClients === 0) {
122
+ if (currentMatches &&
123
+ last.failedClients > 0 &&
124
+ last.readyClients === 0 &&
125
+ typeof last.lastRenderError === "string" &&
126
+ last.lastRenderError) {
123
127
  return {
124
128
  status: "fail",
125
- reason: last.lastRenderError ||
126
- "DeepSeek Harness client failed to render the background.",
129
+ reason: last.lastRenderError,
127
130
  details: { ...last },
128
131
  };
129
132
  }
@@ -2,6 +2,7 @@ import http from "node:http";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
+ import { pipeline } from "node:stream/promises";
5
6
  import { DEFAULT_TRUSTED_ORIGINS, IMAGE_EXTENSIONS, MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, MEDIA_TOKEN_HEADER, MEDIA_TOKEN_HEADER_CANON, TRUSTED_ORIGIN_PREFIXES, VIDEO_EXTENSION, VIDEO_MIME, } from "./constants.js";
6
7
  import { validateImageFile, validateVideoFile, } from "./media-validation.js";
7
8
  function parseRange(value, size) {
@@ -71,6 +72,8 @@ export class LoopbackMediaHub {
71
72
  maxVideoBytes;
72
73
  server = null;
73
74
  sockets = new Set();
75
+ transferControllers = new Set();
76
+ transfers = new Set();
74
77
  assets = new Map();
75
78
  closed = false;
76
79
  port = 0;
@@ -211,6 +214,9 @@ export class LoopbackMediaHub {
211
214
  this.closed = true;
212
215
  this.assets.clear();
213
216
  await this.listenPromise?.catch(() => { });
217
+ for (const controller of this.transferControllers)
218
+ controller.abort();
219
+ await Promise.allSettled([...this.transfers]);
214
220
  await this.closeServerOnly();
215
221
  }
216
222
  toHandle(rec) {
@@ -262,6 +268,44 @@ export class LoopbackMediaHub {
262
268
  setTimeout(resolve, 500).unref?.();
263
269
  });
264
270
  }
271
+ async pipeOpenedFile(opened, request, response, range) {
272
+ const controller = new AbortController();
273
+ const abort = () => controller.abort();
274
+ const abortIfIncomplete = () => {
275
+ if (!response.writableFinished)
276
+ controller.abort();
277
+ };
278
+ request.once("aborted", abort);
279
+ response.once("close", abortIfIncomplete);
280
+ this.transferControllers.add(controller);
281
+ const transfer = (async () => {
282
+ try {
283
+ const stream = opened.createReadStream({
284
+ start: range.start,
285
+ ...(range.end == null ? {} : { end: range.end }),
286
+ // The hub owns the FileHandle lifecycle. This guarantees a close on
287
+ // normal completion, browser Range cancellation, and hub shutdown.
288
+ autoClose: false,
289
+ });
290
+ if (request.aborted || response.destroyed || this.closed)
291
+ controller.abort();
292
+ await pipeline(stream, response, { signal: controller.signal });
293
+ }
294
+ finally {
295
+ request.removeListener("aborted", abort);
296
+ response.removeListener("close", abortIfIncomplete);
297
+ this.transferControllers.delete(controller);
298
+ await opened.close().catch(() => { });
299
+ }
300
+ })();
301
+ this.transfers.add(transfer);
302
+ try {
303
+ await transfer;
304
+ }
305
+ finally {
306
+ this.transfers.delete(transfer);
307
+ }
308
+ }
265
309
  async handle(request, response) {
266
310
  const originHeader = request.headers.origin;
267
311
  const origin = typeof originHeader === "string" ? originHeader : undefined;
@@ -397,11 +441,9 @@ export class LoopbackMediaHub {
397
441
  opened = null;
398
442
  return;
399
443
  }
400
- const stream = opened.createReadStream({ start: 0, autoClose: true });
444
+ const streamingHandle = opened;
401
445
  opened = null;
402
- stream
403
- .on("error", () => response.destroy())
404
- .pipe(response);
446
+ await this.pipeOpenedFile(streamingHandle, request, response, { start: 0 });
405
447
  return;
406
448
  }
407
449
  headers["Content-Range"] =
@@ -414,15 +456,12 @@ export class LoopbackMediaHub {
414
456
  opened = null;
415
457
  return;
416
458
  }
417
- const stream = opened.createReadStream({
459
+ const streamingHandle = opened;
460
+ opened = null;
461
+ await this.pipeOpenedFile(streamingHandle, request, response, {
418
462
  start: range.start,
419
463
  end: range.end,
420
- autoClose: true,
421
464
  });
422
- opened = null;
423
- stream
424
- .on("error", () => response.destroy())
425
- .pipe(response);
426
465
  }
427
466
  catch {
428
467
  await opened?.close().catch(() => { });