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 +26 -14
- package/console.js +170 -130
- package/index.mjs +5 -1
- package/package.json +1 -1
- package/ui-host.mjs +144 -79
- package/vendor/adapter-dsh/bridge.js +6 -3
- package/vendor/core/media-server.js +49 -10
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
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
445
|
-
|
|
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:
|
|
20
|
-
#beauticode-console-pop *{box-sizing:border-box;font-family:inherit}
|
|
21
|
-
body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#
|
|
22
|
-
#beauticode-console-pop .bc-
|
|
23
|
-
#beauticode-console-pop .bc-
|
|
24
|
-
#beauticode-console-pop .bc-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#beauticode-console-pop .bc-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
#beauticode-console-pop .bc-
|
|
31
|
-
#beauticode-console-pop .bc-
|
|
32
|
-
#beauticode-console-pop .bc-
|
|
33
|
-
#beauticode-console-pop .bc-
|
|
34
|
-
|
|
35
|
-
#beauticode-console-pop .bc-theme-
|
|
36
|
-
#beauticode-console-pop .bc-theme-
|
|
37
|
-
#beauticode-console-pop .bc-theme-
|
|
38
|
-
|
|
39
|
-
#beauticode-console-pop .bc-
|
|
40
|
-
#beauticode-console-pop .bc-theme-
|
|
41
|
-
#beauticode-console-pop .bc-theme-
|
|
42
|
-
|
|
43
|
-
#beauticode-console-pop .bc-
|
|
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:
|
|
47
|
-
#beauticode-name-dialog .bc-name-title{margin:0;font
|
|
48
|
-
#beauticode-name-dialog .bc-name-file,#beauticode-name-dialog .bc-name-note,#beauticode-name-dialog .bc-name-error{margin:0;color:#
|
|
49
|
-
#beauticode-name-dialog .bc-name-error{color:#
|
|
50
|
-
#beauticode-name-dialog input{height:
|
|
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
|
|
53
|
-
#beauticode-name-dialog button[data-name="confirm"]{background:#
|
|
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
|
-
'<
|
|
76
|
-
'<
|
|
77
|
-
'<button type="button" class="bc-btn" data-act="
|
|
78
|
-
'<
|
|
79
|
-
"
|
|
80
|
-
'<
|
|
81
|
-
'<button type="button" class="bc-btn" data-act="
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'<
|
|
85
|
-
'<
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
soundBtn.
|
|
188
|
-
|
|
189
|
-
|
|
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)}"
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
themeToggle.
|
|
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('"', """);
|
|
221
229
|
}
|
|
222
230
|
|
|
223
|
-
async function request(path, init) {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
418
|
-
themeList.hidden = !
|
|
419
|
-
themeToggle.
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
"$
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
444
|
+
const streamingHandle = opened;
|
|
401
445
|
opened = null;
|
|
402
|
-
|
|
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
|
|
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(() => { });
|