beauticode-dsh 1.0.12 → 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.12",
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",
package/ui-host.mjs CHANGED
@@ -15,6 +15,7 @@ const MAX_IMAGE_BYTES = 18 * 1024 * 1024;
15
15
  const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
16
16
  const SELECTION_TTL_MS = 5 * 60 * 1000;
17
17
  const MAX_PENDING_SELECTIONS = 16;
18
+ const PICKER_TIMEOUT_MS = 5 * 60 * 1000;
18
19
  const NATIVE_PICKER_UNAVAILABLE = "native_picker_unavailable";
19
20
  const NATIVE_PICKER_REQUIRED = "native_picker_required";
20
21
  const PICKER_REQUEST_ABORTED = "picker_request_aborted";
@@ -107,103 +108,150 @@ function suggestedThemeName(fileName, fallback) {
107
108
  return stem || fallback;
108
109
  }
109
110
 
110
- function pickWindowsMedia(kind, { signal } = {}) {
111
- if (process.platform !== "win32") {
112
- throw pickerUnavailable("当前系统不支持 Windows 原生文件选择器。");
113
- }
111
+ export function buildWindowsPickerScript(
112
+ kind,
113
+ { parentPid = process.pid, timeoutMs = PICKER_TIMEOUT_MS } = {},
114
+ ) {
114
115
  const filter =
115
116
  kind === "video"
116
117
  ? "MP4 Video (*.mp4)|*.mp4"
117
118
  : "Image Files (*.jpg;*.jpeg;*.png;*.webp;*.avif)|*.jpg;*.jpeg;*.png;*.webp;*.avif";
118
- const script = [
119
+ const safeParentPid = Number.isSafeInteger(parentPid) && parentPid > 0
120
+ ? parentPid
121
+ : process.pid;
122
+ const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
123
+ ? Math.ceil(timeoutMs)
124
+ : PICKER_TIMEOUT_MS;
125
+ return [
119
126
  "$ErrorActionPreference = 'Stop'",
120
127
  "$OutputEncoding = New-Object System.Text.UTF8Encoding($false)",
121
128
  "[Console]::OutputEncoding = $OutputEncoding",
122
129
  "Add-Type -AssemblyName System.Windows.Forms",
130
+ "Add-Type -AssemblyName System.Drawing",
123
131
  "[System.Windows.Forms.Application]::EnableVisualStyles()",
132
+ "$owner = New-Object System.Windows.Forms.Form",
133
+ "$owner.Text = 'beautiCode 文件选择器'",
134
+ "$owner.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedToolWindow",
135
+ "$owner.ShowInTaskbar = $false",
136
+ "$owner.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen",
137
+ "$owner.Size = [System.Drawing.Size]::new(1, 1)",
138
+ "$owner.Opacity = 0.01",
139
+ "$owner.TopMost = $true",
124
140
  "$dialog = New-Object System.Windows.Forms.OpenFileDialog",
125
141
  `$dialog.Filter = '${filter}'`,
126
142
  "$dialog.Multiselect = $false",
127
143
  "$dialog.CheckFileExists = $true",
144
+ "$dialog.RestoreDirectory = $true",
128
145
  "$dialog.Title = '选择 beautiCode 背景文件'",
129
- "$result = $dialog.ShowDialog()",
130
- "if ($result -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::WriteLine($dialog.FileName) }",
131
- "$dialog.Dispose()",
146
+ `$dshPid = ${safeParentPid}`,
147
+ `$deadlineUtc = [DateTime]::UtcNow.AddMilliseconds(${safeTimeoutMs})`,
148
+ "$watchdog = New-Object System.Windows.Forms.Timer",
149
+ "$watchdog.Interval = 500",
150
+ "$watchdog.Add_Tick({ if ([DateTime]::UtcNow -ge $deadlineUtc -or -not (Get-Process -Id $dshPid -ErrorAction SilentlyContinue)) { $watchdog.Stop(); [Environment]::Exit(0) } })",
151
+ "try { [void]$owner.Show(); [void]$owner.Hide(); [void]$owner.Show(); [void]$owner.Activate(); [void]$owner.BringToFront(); [System.Windows.Forms.Application]::DoEvents(); if (-not $owner.IsHandleCreated) { throw 'Picker owner window was not created.' }; $watchdog.Start(); $result = $dialog.ShowDialog($owner); if ($result -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::WriteLine($dialog.FileName) } } finally { $watchdog.Stop(); $watchdog.Dispose(); $dialog.Dispose(); $owner.Close(); $owner.Dispose() }",
132
152
  ].join("; ");
133
- return new Promise((resolve, reject) => {
134
- const child = spawn(
135
- "powershell.exe",
136
- [
137
- "-NoLogo",
138
- "-NoProfile",
139
- "-ExecutionPolicy",
140
- "Bypass",
141
- "-STA",
142
- "-Command",
143
- script,
144
- ],
145
- {
146
- // Hide PowerShell's console while leaving the WinForms dialog visible.
147
- windowsHide: true,
148
- stdio: ["ignore", "pipe", "pipe"],
149
- },
150
- );
151
- let stdout = "";
152
- let stderr = "";
153
- let aborted = false;
154
- const onAbort = () => {
155
- aborted = true;
156
- clearTimeout(timer);
157
- child.kill();
158
- const error = new Error("文件选择请求已取消。");
159
- error.code = PICKER_REQUEST_ABORTED;
160
- reject(error);
161
- };
162
- const timer = setTimeout(() => {
163
- signal?.removeEventListener("abort", onAbort);
164
- child.kill();
165
- reject(new Error("文件选择器超时。"));
166
- }, 5 * 60 * 1000);
167
- if (signal?.aborted) onAbort();
168
- else signal?.addEventListener("abort", onAbort, { once: true });
169
- child.stdout.setEncoding("utf8");
170
- child.stderr.setEncoding("utf8");
171
- child.stdout.on("data", (chunk) => { stdout += chunk; });
172
- child.stderr.on("data", (chunk) => { stderr += chunk; });
173
- child.once("error", (error) => {
174
- clearTimeout(timer);
175
- signal?.removeEventListener("abort", onAbort);
176
- if (aborted) return;
177
- reject(
178
- error?.code === "ENOENT"
179
- ? pickerUnavailable("找不到 Windows PowerShell,无法打开原生文件选择器。")
180
- : error,
153
+ }
154
+
155
+ export function createWindowsMediaPicker({
156
+ platform = process.platform,
157
+ spawnProcess = spawn,
158
+ parentProcess = process,
159
+ parentPid = process.pid,
160
+ timeoutMs = PICKER_TIMEOUT_MS,
161
+ } = {}) {
162
+ return function pickWindowsMedia(kind, { signal } = {}) {
163
+ if (platform !== "win32") {
164
+ throw pickerUnavailable("当前系统不支持 Windows 原生文件选择器。");
165
+ }
166
+ const script = buildWindowsPickerScript(kind, { parentPid, timeoutMs });
167
+ const encodedScript = Buffer.from(script, "utf16le").toString("base64");
168
+ return new Promise((resolve, reject) => {
169
+ const child = spawnProcess(
170
+ "powershell.exe",
171
+ [
172
+ "-NoLogo",
173
+ "-NoProfile",
174
+ "-ExecutionPolicy",
175
+ "Bypass",
176
+ "-STA",
177
+ "-EncodedCommand",
178
+ encodedScript,
179
+ ],
180
+ {
181
+ // Hide PowerShell's console while leaving the owned WinForms dialog visible.
182
+ windowsHide: true,
183
+ stdio: ["ignore", "pipe", "pipe"],
184
+ },
181
185
  );
186
+ let stdout = "";
187
+ let stderr = "";
188
+ let settled = false;
189
+ const cleanup = () => {
190
+ clearTimeout(timer);
191
+ signal?.removeEventListener("abort", onAbort);
192
+ parentProcess.removeListener("exit", onParentExit);
193
+ };
194
+ const settle = (callback, value, { terminate = false } = {}) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ cleanup();
198
+ if (terminate && !child.killed) child.kill("SIGKILL");
199
+ callback(value);
200
+ };
201
+ const onAbort = () => {
202
+ const error = new Error("文件选择请求已取消。");
203
+ error.code = PICKER_REQUEST_ABORTED;
204
+ settle(reject, error, { terminate: true });
205
+ };
206
+ const timer = setTimeout(() => {
207
+ settle(reject, new Error("文件选择器超时。"), { terminate: true });
208
+ }, timeoutMs);
209
+ const onParentExit = () => {
210
+ if (!child.killed) child.kill("SIGKILL");
211
+ };
212
+ parentProcess.once("exit", onParentExit);
213
+ if (signal?.aborted) onAbort();
214
+ else signal?.addEventListener("abort", onAbort, { once: true });
215
+ child.stdout.setEncoding("utf8");
216
+ child.stderr.setEncoding("utf8");
217
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
218
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
219
+ child.once("error", (error) => {
220
+ settle(
221
+ reject,
222
+ error?.code === "ENOENT"
223
+ ? pickerUnavailable("找不到 Windows PowerShell,无法打开原生文件选择器。")
224
+ : error,
225
+ );
226
+ });
227
+ child.once("close", (code) => {
228
+ if (settled) return;
229
+ if (code !== 0) {
230
+ const message = stderr.trim() || `文件选择器退出异常(${code})。`;
231
+ settle(
232
+ reject,
233
+ isPickerDependencyError(message) ? pickerUnavailable(message) : new Error(message),
234
+ );
235
+ return;
236
+ }
237
+ const selected = stdout.trim();
238
+ if (!selected) {
239
+ settle(resolve, { ok: true, cancelled: true });
240
+ return;
241
+ }
242
+ const name = path.basename(selected.replaceAll("\\", "/"));
243
+ const parsed = parseImportFilename(name);
244
+ if (!parsed.ok || parsed.kind !== kind) {
245
+ settle(reject, new Error(parsed.ok ? "选择的文件类型不匹配。" : parsed.error));
246
+ return;
247
+ }
248
+ settle(resolve, { ok: true, kind, path: path.resolve(selected), name });
249
+ });
182
250
  });
183
- child.once("close", (code) => {
184
- clearTimeout(timer);
185
- signal?.removeEventListener("abort", onAbort);
186
- if (aborted) return;
187
- if (code !== 0) {
188
- const message = stderr.trim() || `文件选择器退出异常(${code})。`;
189
- reject(isPickerDependencyError(message) ? pickerUnavailable(message) : new Error(message));
190
- return;
191
- }
192
- const selected = stdout.trim();
193
- if (!selected) {
194
- resolve({ ok: true, cancelled: true });
195
- return;
196
- }
197
- const name = path.basename(selected.replaceAll("\\", "/"));
198
- const parsed = parseImportFilename(name);
199
- if (!parsed.ok || parsed.kind !== kind) {
200
- reject(new Error(parsed.ok ? "选择的文件类型不匹配。" : parsed.error));
201
- return;
202
- }
203
- resolve({ ok: true, kind, path: path.resolve(selected), name });
204
- });
205
- });
251
+ };
206
252
  }
253
+
254
+ const pickWindowsMedia = createWindowsMediaPicker();
207
255
 
208
256
  function limitBytes(maxBytes) {
209
257
  let size = 0;
@@ -277,6 +325,9 @@ export function createBeauticodeUi({
277
325
  const pendingSelections = new Map();
278
326
  let restoreStarted = false;
279
327
  let pickerBusy = false;
328
+ let activePickerAbort = null;
329
+ let activePickerResponse = null;
330
+ let disposed = false;
280
331
 
281
332
  function pruneSelections() {
282
333
  const timestamp = now();
@@ -336,6 +387,12 @@ export function createBeauticodeUi({
336
387
 
337
388
  return {
338
389
  dispose() {
390
+ disposed = true;
391
+ activePickerAbort?.abort();
392
+ activePickerResponse?.destroy();
393
+ activePickerAbort = null;
394
+ activePickerResponse = null;
395
+ pickerBusy = false;
339
396
  pendingSelections.clear();
340
397
  return stopInProcessSession(dataRoot);
341
398
  },
@@ -485,9 +542,15 @@ export function createBeauticodeUi({
485
542
  sendJson(res, 409, { ok: false, error: "文件选择器已经打开。" });
486
543
  return;
487
544
  }
545
+ if (disposed) {
546
+ sendJson(res, 503, { ok: false, error: "beautiCode UI 已停止。" });
547
+ return;
548
+ }
488
549
  pickerBusy = true;
489
550
  const pickerStartedAt = performance.now();
490
551
  const pickerAbort = new AbortController();
552
+ activePickerAbort = pickerAbort;
553
+ activePickerResponse = res;
491
554
  const onResponseClose = () => {
492
555
  if (!res.writableEnded) pickerAbort.abort();
493
556
  };
@@ -517,6 +580,8 @@ export function createBeauticodeUi({
517
580
  });
518
581
  } finally {
519
582
  res.off("close", onResponseClose);
583
+ if (activePickerAbort === pickerAbort) activePickerAbort = null;
584
+ if (activePickerResponse === res) activePickerResponse = null;
520
585
  pickerBusy = false;
521
586
  }
522
587
  },
@@ -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(() => { });