beauticode-dsh 1.0.21 → 1.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.mjs +47 -27
- package/atmosphere.js +3 -3
- package/cli.js +15 -3
- package/client.js +109 -49
- package/console.js +15 -5
- package/gallery-host.mjs +114 -16
- package/gallery.js +46 -24
- package/host-apply.mjs +3 -3
- package/index.mjs +57 -43
- package/package.json +1 -1
- package/themes/internal-beyond/NOTICE.md +9 -9
- package/ui-host.mjs +6 -2
- package/vendor/adapter-dsh/session.js +35 -10
- package/vendor/core/apply-transaction.js +8 -2
- package/vendor/core/constants.js +5 -0
- package/vendor/core/index.js +1 -0
- package/vendor/core/media-source.js +2 -0
- package/vendor/core/process-liveness.js +81 -0
package/agent.mjs
CHANGED
|
@@ -124,6 +124,23 @@ function unwrapApply(result, fallbackMode, message) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// The raw manifest exposes a local source's absolute on-disk path. The status
|
|
128
|
+
// tool result is surfaced to the model, so only forward a safe summary.
|
|
129
|
+
function sanitizeBackground(background) {
|
|
130
|
+
if (!background) return null;
|
|
131
|
+
const safe = {
|
|
132
|
+
type: background.type,
|
|
133
|
+
...(background.effects ? { effects: background.effects } : {}),
|
|
134
|
+
};
|
|
135
|
+
if (background.source) {
|
|
136
|
+
safe.source =
|
|
137
|
+
background.source.kind === "local"
|
|
138
|
+
? { kind: "local" }
|
|
139
|
+
: { kind: "managed", file: background.source.file };
|
|
140
|
+
}
|
|
141
|
+
return safe;
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
function presentStatus(status) {
|
|
128
145
|
const background = status.manifest?.background ?? status.background ?? null;
|
|
129
146
|
return {
|
|
@@ -133,7 +150,7 @@ function presentStatus(status) {
|
|
|
133
150
|
fish: status.fish === true,
|
|
134
151
|
muted: status.muted !== false,
|
|
135
152
|
tone: status.tone ?? "dark",
|
|
136
|
-
background,
|
|
153
|
+
background: sanitizeBackground(background),
|
|
137
154
|
sourceMode: background
|
|
138
155
|
? background.source?.kind === "local"
|
|
139
156
|
? "local"
|
|
@@ -350,33 +367,36 @@ export function createBeauticodeActions(dataRootOrOptions) {
|
|
|
350
367
|
const name = String(input?.name ?? "").trim();
|
|
351
368
|
const imagePath = String(input?.imagePath ?? "").trim();
|
|
352
369
|
if (!name || !imagePath) fail("导入皮肤必须提供名称和图片。");
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
370
|
+
const source = input?.source === "managed" ? "managed" : "local";
|
|
371
|
+
const hasVideo = typeof input.videoPath === "string" && Boolean(input.videoPath.trim());
|
|
372
|
+
const themeInput = hasVideo
|
|
373
|
+
? {
|
|
374
|
+
type: "video",
|
|
375
|
+
videoPath: input.videoPath.trim(),
|
|
376
|
+
imagePath,
|
|
377
|
+
source,
|
|
378
|
+
}
|
|
379
|
+
: { type: "image", imagePath, source };
|
|
380
|
+
if (!hasVideo && input.effects) themeInput.effects = input.effects;
|
|
362
381
|
const resolved = await backend();
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
382
|
+
// Gallery import applies-and-saves through the same atomic transaction as
|
|
383
|
+
// the normal image/video flow. There is no separate import-only endpoint:
|
|
384
|
+
// the tray exposes /theme/apply and DshSession exposes applyAndSaveTheme.
|
|
385
|
+
const result =
|
|
386
|
+
resolved.kind === "tray"
|
|
387
|
+
? await request({
|
|
388
|
+
method: "POST",
|
|
389
|
+
path: "/theme/apply",
|
|
390
|
+
body: { name, input: themeInput },
|
|
391
|
+
signal,
|
|
392
|
+
timeoutMs: 30 * 60 * 1000,
|
|
393
|
+
})
|
|
394
|
+
: await resolved.session.applyAndSaveTheme(themeInput, name);
|
|
395
|
+
return unwrapApplyResult(
|
|
396
|
+
result,
|
|
397
|
+
hasVideo ? "video" : "image",
|
|
398
|
+
`已保存皮肤「${name}」。`,
|
|
399
|
+
);
|
|
380
400
|
},
|
|
381
401
|
|
|
382
402
|
async listThemes(signal) {
|
package/atmosphere.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
(() => {
|
|
1
|
+
(() => {
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
const STYLE_ID = "beauticode-atmosphere-style";
|
|
@@ -92,8 +92,8 @@ html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{
|
|
|
92
92
|
}
|
|
93
93
|
html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important}
|
|
94
94
|
html[data-bc-gallery="true"] [class*="_fade"]{display:none!important}
|
|
95
|
-
html[data-bc-gallery="true"] #beauticode-bg-stage img,
|
|
96
|
-
html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
|
|
95
|
+
html[data-bc-gallery="true"] #beauticode-bg-stage img,
|
|
96
|
+
html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
|
|
97
97
|
html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
|
|
98
98
|
`;
|
|
99
99
|
return style;
|
package/cli.js
CHANGED
|
@@ -298,14 +298,26 @@ async function sameLinkTarget(link, dest) {
|
|
|
298
298
|
return false;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// A dangling symlink has a live directory entry even though fs.existsSync
|
|
302
|
+
// (which follows the link) reports false. Use lstat so leftover plugin links
|
|
303
|
+
// are detected and cleaned instead of making fsp.symlink fail with EEXIST.
|
|
304
|
+
async function entryExists(p) {
|
|
305
|
+
try {
|
|
306
|
+
await fsp.lstat(p);
|
|
307
|
+
return true;
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
301
313
|
async function linkPluginIntoProfile(webProfile, dest) {
|
|
302
314
|
const link = pluginLinkPath(webProfile);
|
|
303
315
|
const legacy = legacyPluginLinkPath(webProfile);
|
|
304
|
-
if (
|
|
316
|
+
if (await entryExists(legacy)) {
|
|
305
317
|
await fsp.rm(legacy, { recursive: true, force: true });
|
|
306
318
|
}
|
|
307
319
|
await fsp.mkdir(path.dirname(link), { recursive: true });
|
|
308
|
-
if (
|
|
320
|
+
if (await entryExists(link)) {
|
|
309
321
|
if (await sameLinkTarget(link, dest)) return;
|
|
310
322
|
await fsp.rm(link, { recursive: true, force: true });
|
|
311
323
|
}
|
|
@@ -403,7 +415,7 @@ async function uninstall(opts) {
|
|
|
403
415
|
removed.push("web package.json");
|
|
404
416
|
}
|
|
405
417
|
for (const link of [pluginLinkPath(webProfile), legacyPluginLinkPath(webProfile)]) {
|
|
406
|
-
if (
|
|
418
|
+
if (await entryExists(link)) {
|
|
407
419
|
await fsp.rm(link, { recursive: true, force: true });
|
|
408
420
|
removed.push(link);
|
|
409
421
|
}
|
package/client.js
CHANGED
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
const IMAGE_MAX_ATTEMPTS = 2;
|
|
18
18
|
const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
19
19
|
const VIDEO_PROBE_TIMEOUT_MS = 2_000;
|
|
20
|
+
// Phase-one playback acceptance window. A muted play() that is still pending
|
|
21
|
+
// after this only means the decoder is warming; the settle phase owns it.
|
|
22
|
+
const PLAY_ACCEPT_TIMEOUT_MS = 1_500;
|
|
23
|
+
// Phase-two budget for the committed video to present its first frame. It is
|
|
24
|
+
// deliberately far beyond any host deadline: a poster already committed, so
|
|
25
|
+
// slow first frames upgrade in place instead of failing the transaction.
|
|
26
|
+
const VIDEO_SETTLE_TIMEOUT_MS = 60_000;
|
|
20
27
|
const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
21
28
|
const FRAME_FALLBACK_MS = 120;
|
|
22
29
|
const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03;
|
|
@@ -32,6 +39,7 @@
|
|
|
32
39
|
let playbackBlocked = false;
|
|
33
40
|
let currentSlot = null;
|
|
34
41
|
let applyController = null;
|
|
42
|
+
let videoSettleController = null;
|
|
35
43
|
const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
36
44
|
const reducedMotionMedia =
|
|
37
45
|
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null;
|
|
@@ -298,10 +306,14 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
298
306
|
};
|
|
299
307
|
}
|
|
300
308
|
|
|
301
|
-
async function acknowledgeRender(payload, ok, visible, error = null) {
|
|
309
|
+
async function acknowledgeRender(payload, ok, visible, error = null, extra = {}) {
|
|
302
310
|
if (activePayload === payload) {
|
|
303
311
|
renderPhase = ok ? "ready" : "failed";
|
|
304
312
|
}
|
|
313
|
+
// A committed video apply may still be settling: ok=true with
|
|
314
|
+
// videoReady=false means the poster is live and the first frame upgrades
|
|
315
|
+
// in place. Only ok=false is a renderer verdict the host will act on.
|
|
316
|
+
const videoReady = extra.videoReady !== false;
|
|
305
317
|
await postAck({
|
|
306
318
|
kind: "render",
|
|
307
319
|
generation: payload.generation,
|
|
@@ -309,8 +321,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
309
321
|
ok,
|
|
310
322
|
visible,
|
|
311
323
|
error,
|
|
324
|
+
...(payload.media === "video" ? { videoReady } : {}),
|
|
312
325
|
playback:
|
|
313
|
-
ok && committedPayload === payload && payload.media === "video"
|
|
326
|
+
ok && committedPayload === payload && payload.media === "video" && videoReady
|
|
314
327
|
? playbackSnapshot(activeVideo())
|
|
315
328
|
: null,
|
|
316
329
|
});
|
|
@@ -672,6 +685,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
672
685
|
function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
|
|
673
686
|
const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
|
|
674
687
|
if (video.readyState >= frameReadyState) return Promise.resolve();
|
|
688
|
+
// A decoder error may fire before this waiter attaches — most notably
|
|
689
|
+
// between the poster commit's crossfade and the settle start. Honoring an
|
|
690
|
+
// already-set error keeps the settle phase from waiting out its full
|
|
691
|
+
// budget on media that has already failed.
|
|
692
|
+
if (video.error) {
|
|
693
|
+
return Promise.reject(new Error(describeVideoState(video, "MP4 加载或解码失败")));
|
|
694
|
+
}
|
|
675
695
|
return new Promise((resolve, reject) => {
|
|
676
696
|
let settled = false;
|
|
677
697
|
const done = () => finish();
|
|
@@ -990,36 +1010,32 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
990
1010
|
return blocked;
|
|
991
1011
|
}
|
|
992
1012
|
|
|
1013
|
+
// Phase one only needs playback initiated. On a cold machine the muted
|
|
1014
|
+
// play() promise can stay pending well past the apply deadline while the
|
|
1015
|
+
// decoder warms; that is not a failure — the poster-first commit tolerates
|
|
1016
|
+
// it and the settle phase owns the eventual outcome. Only an outright
|
|
1017
|
+
// rejection (autoplay policy, detached element) fails the apply.
|
|
993
1018
|
async function startCandidatePlayback(video, signal, timeoutMs) {
|
|
994
1019
|
const playbackController = new AbortController();
|
|
995
1020
|
const parentAborted = () => playbackController.abort();
|
|
996
1021
|
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
997
|
-
let
|
|
1022
|
+
let acceptTimer = null;
|
|
998
1023
|
let abortListener = null;
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
|
|
1003
|
-
playbackController.abort();
|
|
1004
|
-
}, timeoutMs);
|
|
1005
|
-
abortListener = () => reject(abortError());
|
|
1024
|
+
const accepted = new Promise((resolve) => {
|
|
1025
|
+
acceptTimer = setTimeout(() => resolve("accepted"), Math.max(1, timeoutMs));
|
|
1026
|
+
abortListener = () => resolve("aborted");
|
|
1006
1027
|
playbackController.signal.addEventListener("abort", abortListener, { once: true });
|
|
1007
1028
|
});
|
|
1008
1029
|
if (signal?.aborted) parentAborted();
|
|
1030
|
+
const playback = playWithPreference(video, playbackController.signal);
|
|
1031
|
+
// The settle phase owns late outcomes of an accepted-but-unresolved play().
|
|
1032
|
+
void playback.catch?.(() => {});
|
|
1009
1033
|
try {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
return
|
|
1013
|
-
} catch (error) {
|
|
1014
|
-
playbackController.abort();
|
|
1015
|
-
try {
|
|
1016
|
-
video.pause();
|
|
1017
|
-
} catch {
|
|
1018
|
-
/* best effort: disposeSlot will release src as the final guard */
|
|
1019
|
-
}
|
|
1020
|
-
throw error;
|
|
1034
|
+
const outcome = await Promise.race([playback.then(() => "started"), accepted]);
|
|
1035
|
+
if (outcome === "aborted") throw abortError();
|
|
1036
|
+
return outcome;
|
|
1021
1037
|
} finally {
|
|
1022
|
-
|
|
1038
|
+
clearTimeout(acceptTimer);
|
|
1023
1039
|
signal?.removeEventListener?.("abort", parentAborted);
|
|
1024
1040
|
if (abortListener) {
|
|
1025
1041
|
playbackController.signal.removeEventListener("abort", abortListener);
|
|
@@ -1111,7 +1127,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1111
1127
|
return true;
|
|
1112
1128
|
}
|
|
1113
1129
|
|
|
1114
|
-
function updateCommittedDom(payload) {
|
|
1130
|
+
function updateCommittedDom(payload, videoReady = true) {
|
|
1115
1131
|
document.documentElement.dataset.bcGeneration = String(payload.generation);
|
|
1116
1132
|
document.documentElement.removeAttribute("data-bc-pending-generation");
|
|
1117
1133
|
if (payload.media === "clear") {
|
|
@@ -1122,8 +1138,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1122
1138
|
}
|
|
1123
1139
|
document.documentElement.dataset.bcActive = "true";
|
|
1124
1140
|
document.documentElement.dataset.bcMedia = payload.media;
|
|
1125
|
-
if (payload.media === "video")
|
|
1126
|
-
|
|
1141
|
+
if (payload.media === "video") {
|
|
1142
|
+
// "false" keeps the poster as the committed visual until the settle
|
|
1143
|
+
// phase flips it after the first presented frame.
|
|
1144
|
+
document.documentElement.dataset.bcVideoReady = videoReady ? "true" : "false";
|
|
1145
|
+
} else {
|
|
1146
|
+
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1147
|
+
}
|
|
1127
1148
|
}
|
|
1128
1149
|
|
|
1129
1150
|
function discardCandidates() {
|
|
@@ -1240,7 +1261,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1240
1261
|
committedPayload = payload;
|
|
1241
1262
|
const video = activeVideo();
|
|
1242
1263
|
playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
|
|
1243
|
-
updateCommittedDom(payload);
|
|
1264
|
+
updateCommittedDom(payload, slot.dataset.bcVideoReady === "true");
|
|
1244
1265
|
syncGallery(payload);
|
|
1245
1266
|
}
|
|
1246
1267
|
|
|
@@ -1249,7 +1270,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1249
1270
|
node?.removeAttribute("data-bc-transitioning");
|
|
1250
1271
|
node?.removeAttribute("data-bc-empty");
|
|
1251
1272
|
if (committedPayload && mountedCurrentSlot()) {
|
|
1252
|
-
updateCommittedDom(committedPayload);
|
|
1273
|
+
updateCommittedDom(committedPayload, currentSlot.dataset.bcVideoReady === "true");
|
|
1253
1274
|
playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
|
|
1254
1275
|
return;
|
|
1255
1276
|
}
|
|
@@ -1369,40 +1390,29 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1369
1390
|
candidateVideo = video;
|
|
1370
1391
|
video.src = payload.videoUrl;
|
|
1371
1392
|
video.load();
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1393
|
+
// Phase one gates only what the poster commit needs: the media source
|
|
1394
|
+
// answers Range requests, the poster decodes, and playback was
|
|
1395
|
+
// initiated. A cold decoder no longer fails the transaction here — the
|
|
1396
|
+
// video upgrades from the committed poster in the settle phase, so a
|
|
1397
|
+
// fresh machine that merely loads slowly can never time out.
|
|
1375
1398
|
const sourceProbe = probeVideoSource(
|
|
1376
1399
|
payload.videoUrl,
|
|
1377
1400
|
signal,
|
|
1378
1401
|
Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
|
|
1379
1402
|
);
|
|
1380
1403
|
const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1381
|
-
// play() is deliberately started alongside media readiness. Chromium is
|
|
1382
|
-
// allowed to ignore preload=auto for hidden/background media; waiting for
|
|
1383
|
-
// canplay before play() creates a circular stall at readyState=0.
|
|
1384
1404
|
await Promise.all([
|
|
1385
1405
|
sourceProbe,
|
|
1386
1406
|
imageReady,
|
|
1387
|
-
|
|
1388
|
-
startCandidatePlayback(video, signal, remaining()),
|
|
1407
|
+
startCandidatePlayback(video, signal, Math.min(PLAY_ACCEPT_TIMEOUT_MS, remaining())),
|
|
1389
1408
|
]);
|
|
1390
1409
|
throwIfAborted(signal);
|
|
1391
|
-
const appliedStartAt = seekVideo(video, payload.startAt);
|
|
1392
|
-
candidate.dataset.bcStartAt = String(appliedStartAt);
|
|
1393
|
-
await waitForSeek(video, signal, remaining());
|
|
1394
|
-
await waitForVideo(video, signal, remaining());
|
|
1395
|
-
// Transaction success means one frame was actually presented. Longer
|
|
1396
|
-
// three-frame stability remains useful for reusing an existing slot, but
|
|
1397
|
-
// must not turn a healthy cold decoder into a false first-frame failure.
|
|
1398
|
-
await waitForPresentedFrame(video, signal, remaining());
|
|
1399
|
-
throwIfAborted(signal);
|
|
1400
|
-
candidate.dataset.bcVideoReady = "true";
|
|
1401
1410
|
await commitCandidate(payload, candidate, signal, remaining);
|
|
1402
1411
|
committed = true;
|
|
1403
|
-
|
|
1404
|
-
await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
|
|
1412
|
+
await acknowledgeRender(payload, true, true, null, { videoReady: false });
|
|
1405
1413
|
await acknowledgeMode();
|
|
1414
|
+
settleCommittedVideo(video, candidate, payload);
|
|
1415
|
+
return;
|
|
1406
1416
|
} catch (error) {
|
|
1407
1417
|
if (candidateVideo && candidateVideo.parentElement !== candidate) {
|
|
1408
1418
|
disposeVideo(candidateVideo);
|
|
@@ -1420,6 +1430,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1420
1430
|
}
|
|
1421
1431
|
}
|
|
1422
1432
|
|
|
1433
|
+
// Phase two of a video apply. The poster is already committed and acked, so
|
|
1434
|
+
// nothing here can roll the transaction back: a decoder that needs longer
|
|
1435
|
+
// than any host deadline simply keeps the poster until the first frame
|
|
1436
|
+
// presents, then upgrades the committed slot in place without a flash.
|
|
1437
|
+
function settleCommittedVideo(video, slot, payload) {
|
|
1438
|
+
videoSettleController?.abort();
|
|
1439
|
+
const controller = new AbortController();
|
|
1440
|
+
videoSettleController = controller;
|
|
1441
|
+
const stillSettling = () => !controller.signal.aborted && mountedCurrentSlot() === slot;
|
|
1442
|
+
void (async () => {
|
|
1443
|
+
try {
|
|
1444
|
+
await waitForVideo(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1445
|
+
throwIfAborted(controller.signal);
|
|
1446
|
+
if (!stillSettling()) return;
|
|
1447
|
+
const appliedStartAt = seekVideo(video, payload.startAt);
|
|
1448
|
+
slot.dataset.bcStartAt = String(appliedStartAt);
|
|
1449
|
+
await waitForSeek(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1450
|
+
await waitForVideo(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1451
|
+
await waitForPresentedFrame(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1452
|
+
if (!stillSettling()) return;
|
|
1453
|
+
slot.dataset.bcVideoReady = "true";
|
|
1454
|
+
updateCommittedDom(payload, true);
|
|
1455
|
+
playbackBlocked = video.dataset?.bcPlaybackBlocked === "true";
|
|
1456
|
+
await acknowledgeRender(payload, true, true, null, { videoReady: true });
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
if (isAbortError(error) || controller.signal.aborted || !stillSettling()) return;
|
|
1459
|
+
// Keep the committed poster; drop the stalled decoder so a later apply
|
|
1460
|
+
// or re-import starts from a clean media element.
|
|
1461
|
+
disposeVideo(video);
|
|
1462
|
+
video.remove?.();
|
|
1463
|
+
slot.removeAttribute("data-bc-video-ready");
|
|
1464
|
+
await acknowledgeRender(
|
|
1465
|
+
payload,
|
|
1466
|
+
true,
|
|
1467
|
+
true,
|
|
1468
|
+
`视频预热未完成(已保留封面):${error instanceof Error ? error.message : String(error)}`,
|
|
1469
|
+
{ videoReady: false },
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
})();
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1423
1475
|
function scheduleBackground(payload) {
|
|
1424
1476
|
if (!Number.isSafeInteger(payload?.generation)) return;
|
|
1425
1477
|
// EventSource reconnects can replay the current frame while its first
|
|
@@ -1439,6 +1491,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1439
1491
|
return;
|
|
1440
1492
|
}
|
|
1441
1493
|
applyController?.abort();
|
|
1494
|
+
videoSettleController?.abort();
|
|
1495
|
+
videoSettleController = null;
|
|
1442
1496
|
discardCandidates();
|
|
1443
1497
|
const controller = new AbortController();
|
|
1444
1498
|
applyController = controller;
|
|
@@ -1489,7 +1543,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1489
1543
|
activePayload === committedPayload &&
|
|
1490
1544
|
renderPhase === "ready"
|
|
1491
1545
|
) {
|
|
1492
|
-
await acknowledgeRender(committedPayload, true, true
|
|
1546
|
+
await acknowledgeRender(committedPayload, true, true, null, {
|
|
1547
|
+
videoReady: mountedCurrentSlot()?.dataset.bcVideoReady === "true",
|
|
1548
|
+
});
|
|
1493
1549
|
}
|
|
1494
1550
|
} else {
|
|
1495
1551
|
playbackBlocked = false;
|
|
@@ -1544,7 +1600,11 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1544
1600
|
// A heartbeat is observational, not a second render verdict. Playback can
|
|
1545
1601
|
// briefly pause while Chromium changes modes or refills an 8K buffer; do
|
|
1546
1602
|
// not downgrade an already-rendered generation or fail a pending one.
|
|
1547
|
-
|
|
1603
|
+
// Report the real first-frame state: a committed video that still only
|
|
1604
|
+
// shows its poster must not be acked as video-ready (see settleCommittedVideo).
|
|
1605
|
+
void acknowledgeRender(committedPayload, true, true, null, {
|
|
1606
|
+
videoReady: slot.dataset.bcVideoReady === "true",
|
|
1607
|
+
}).catch(() => {});
|
|
1548
1608
|
}
|
|
1549
1609
|
}, 1_000);
|
|
1550
1610
|
})();
|
package/console.js
CHANGED
|
@@ -128,12 +128,15 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
|
|
|
128
128
|
|
|
129
129
|
function place() {
|
|
130
130
|
const settings = findSettingsTrigger();
|
|
131
|
-
|
|
131
|
+
const row = settings?.parentElement;
|
|
132
|
+
const settingsArea = row?.parentElement;
|
|
133
|
+
const footArea = settingsArea?.parentElement;
|
|
134
|
+
if (!settings || !settingsArea || !footArea) {
|
|
132
135
|
if (host.parentElement) host.remove();
|
|
133
136
|
return;
|
|
134
137
|
}
|
|
135
|
-
if (host.parentElement !==
|
|
136
|
-
|
|
138
|
+
if (host.parentElement !== footArea || host.nextElementSibling !== settingsArea) {
|
|
139
|
+
footArea.insertBefore(host, settingsArea);
|
|
137
140
|
}
|
|
138
141
|
host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40);
|
|
139
142
|
if (!pop.hidden) placePop();
|
|
@@ -252,7 +255,12 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
|
|
|
252
255
|
return body;
|
|
253
256
|
} catch (error) {
|
|
254
257
|
if (controller?.signal.aborted) {
|
|
255
|
-
|
|
258
|
+
// The browser only cancels its own fetch; a slow backend transaction
|
|
259
|
+
// (managed-video copy or verify) may still commit afterwards. Don't
|
|
260
|
+
// claim the previous background was preserved — report honestly.
|
|
261
|
+
throw new Error(
|
|
262
|
+
"背景操作超时,控件已恢复。操作可能仍在后台进行,请先查看当前背景状态再重试。",
|
|
263
|
+
);
|
|
256
264
|
}
|
|
257
265
|
throw error;
|
|
258
266
|
} finally {
|
|
@@ -427,7 +435,9 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
|
|
|
427
435
|
event.stopPropagation();
|
|
428
436
|
setOpen(false);
|
|
429
437
|
if (window.BeauticodeGallery) {
|
|
430
|
-
|
|
438
|
+
window.BeauticodeGallery.open().catch((error) => {
|
|
439
|
+
showMessage(error instanceof Error ? error.message : String(error));
|
|
440
|
+
});
|
|
431
441
|
return;
|
|
432
442
|
}
|
|
433
443
|
showMessage("皮肤中心脚本尚未加载。");
|
package/gallery-host.mjs
CHANGED
|
@@ -12,6 +12,9 @@ const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
|
|
|
12
12
|
const MAX_IMAGE_BYTES = 18 * 1024 * 1024;
|
|
13
13
|
const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
|
|
14
14
|
const INSTALL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
15
|
+
// Small catalog/metadata requests get a tight budget; the media download uses
|
|
16
|
+
// INSTALL_TIMEOUT_MS so a large video on a slow link can still finish.
|
|
17
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
15
18
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
16
19
|
|
|
17
20
|
export function isSafeSkinId(id) {
|
|
@@ -58,12 +61,19 @@ export function skinUrl(center, id, part = "") {
|
|
|
58
61
|
return part ? `${origin}/api/skins/${id}/${part}` : `${origin}/api/skins/${id}`;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress } = {}) {
|
|
64
|
+
export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress, signal } = {}) {
|
|
62
65
|
const expected = new URL(url);
|
|
63
66
|
if (expectedOrigin && expected.origin !== expectedOrigin) {
|
|
64
67
|
throw new Error("Skin media download host mismatch.");
|
|
65
68
|
}
|
|
66
|
-
|
|
69
|
+
// Bound every media download; a stalling skin center must not leave the
|
|
70
|
+
// gallery request open forever. Callers may pass an AbortSignal to cancel
|
|
71
|
+
// early when the browser client disconnects.
|
|
72
|
+
const timeoutSignal = AbortSignal.timeout(INSTALL_TIMEOUT_MS);
|
|
73
|
+
const response = await fetch(url, {
|
|
74
|
+
redirect: "follow",
|
|
75
|
+
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
|
76
|
+
});
|
|
67
77
|
if (!response.ok || !response.body) {
|
|
68
78
|
throw new Error("Skin media download failed.");
|
|
69
79
|
}
|
|
@@ -77,6 +87,8 @@ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onPr
|
|
|
77
87
|
}
|
|
78
88
|
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
79
89
|
let size = 0;
|
|
90
|
+
let lastReportedAt = 0;
|
|
91
|
+
let lastReportedBytes = 0;
|
|
80
92
|
const limiter = new Transform({
|
|
81
93
|
transform(chunk, _enc, callback) {
|
|
82
94
|
size += chunk.length;
|
|
@@ -84,12 +96,26 @@ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onPr
|
|
|
84
96
|
callback(new Error("Skin media download exceeded the size limit."));
|
|
85
97
|
return;
|
|
86
98
|
}
|
|
87
|
-
|
|
99
|
+
// Throttle progress frames: one every 250 ms or every 1 MiB, whichever
|
|
100
|
+
// comes first. A chunk-level frame floods the NDJSON stream on large
|
|
101
|
+
// videos and forces a DOM write per chunk in the browser client.
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
if (now - lastReportedAt >= 250 || size - lastReportedBytes >= 1024 * 1024) {
|
|
104
|
+
lastReportedAt = now;
|
|
105
|
+
lastReportedBytes = size;
|
|
106
|
+
onProgress?.(size, Number.isFinite(length) ? length : 0);
|
|
107
|
+
}
|
|
88
108
|
callback(null, chunk);
|
|
89
109
|
},
|
|
110
|
+
flush(callback) {
|
|
111
|
+
if (size !== lastReportedBytes) {
|
|
112
|
+
onProgress?.(size, Number.isFinite(length) ? length : 0);
|
|
113
|
+
}
|
|
114
|
+
callback();
|
|
115
|
+
},
|
|
90
116
|
});
|
|
91
117
|
await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(dest));
|
|
92
|
-
return { bytes: size };
|
|
118
|
+
return { bytes: size, contentType: response.headers.get("content-type") ?? null };
|
|
93
119
|
}
|
|
94
120
|
|
|
95
121
|
function extensionOf(url, fallback) {
|
|
@@ -102,6 +128,38 @@ function extensionOf(url, fallback) {
|
|
|
102
128
|
return fallback;
|
|
103
129
|
}
|
|
104
130
|
|
|
131
|
+
// Theme names are persisted via saveCurrentTheme, which rejects characters that
|
|
132
|
+
// are illegal in filenames. Normalize gallery skin display names the same way
|
|
133
|
+
// local filenames are normalized so a name like "Cyberpunk: Neon" still installs.
|
|
134
|
+
function normalizeThemeName(value, fallback = "主题") {
|
|
135
|
+
const name = String(value ?? "")
|
|
136
|
+
.replace(/[<>:"/\\|?*]/g, " ")
|
|
137
|
+
.replace(/[\u0000-\u001f]/g, "")
|
|
138
|
+
.replace(/\s+/g, " ")
|
|
139
|
+
.trim()
|
|
140
|
+
.slice(0, 80);
|
|
141
|
+
return name || fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The /image endpoint is extensionless; derive the on-disk suffix from the
|
|
145
|
+
// response content-type so JPEG/WebP are not mislabeled as .png (which the
|
|
146
|
+
// image validator would then reject on a magic-byte mismatch).
|
|
147
|
+
function extensionForContentType(contentType) {
|
|
148
|
+
const mime = String(contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
149
|
+
switch (mime) {
|
|
150
|
+
case "image/jpeg":
|
|
151
|
+
return ".jpg";
|
|
152
|
+
case "image/png":
|
|
153
|
+
return ".png";
|
|
154
|
+
case "image/webp":
|
|
155
|
+
return ".webp";
|
|
156
|
+
case "image/avif":
|
|
157
|
+
return ".avif";
|
|
158
|
+
default:
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
105
163
|
export function createGalleryHandlers({ dataRoot, actions }) {
|
|
106
164
|
async function importTheme(input) {
|
|
107
165
|
if (typeof actions.importTheme === "function") {
|
|
@@ -143,9 +201,24 @@ export function createGalleryHandlers({ dataRoot, actions }) {
|
|
|
143
201
|
return;
|
|
144
202
|
}
|
|
145
203
|
const incoming = new URL(req.url || "/", "http://127.0.0.1");
|
|
146
|
-
|
|
204
|
+
// Resolve relative to the center's own path so a base like
|
|
205
|
+
// https://host/beauticode keeps its prefix (skinUrl already does).
|
|
206
|
+
const target = new URL("api/catalog", `${center}/`);
|
|
147
207
|
target.search = incoming.search;
|
|
148
|
-
|
|
208
|
+
let response;
|
|
209
|
+
try {
|
|
210
|
+
response = await fetch(target, {
|
|
211
|
+
headers: { accept: "application/json" },
|
|
212
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
sendJson(res, 502, {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: "无法连接皮肤中心,请稍后重试。",
|
|
218
|
+
skins: [],
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
149
222
|
const body = await response.json().catch(() => null);
|
|
150
223
|
if (!response.ok || !body || body.ok === false) {
|
|
151
224
|
sendJson(res, 422, {
|
|
@@ -192,9 +265,18 @@ export function createGalleryHandlers({ dataRoot, actions }) {
|
|
|
192
265
|
if (!res.writableEnded) res.write(`${JSON.stringify(payload)}\n`);
|
|
193
266
|
};
|
|
194
267
|
const tmpDir = path.join(dataRoot, "tmp", "gallery", `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
|
|
268
|
+
// Cancel the download when the browser client disconnects (e.g. the UI
|
|
269
|
+
// timeout aborts its fetch); otherwise a slow skin download keeps going
|
|
270
|
+
// and can still commit a background after the controls recovered.
|
|
271
|
+
const clientAbort = new AbortController();
|
|
272
|
+
const onClose = () => clientAbort.abort(new Error("客户端已断开。"));
|
|
273
|
+
res.once("close", onClose);
|
|
195
274
|
try {
|
|
196
275
|
write({ phase: "fetch" });
|
|
197
|
-
const metaRes = await fetch(skinUrl(center, id), {
|
|
276
|
+
const metaRes = await fetch(skinUrl(center, id), {
|
|
277
|
+
headers: { accept: "application/json" },
|
|
278
|
+
signal: AbortSignal.any([clientAbort.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
279
|
+
});
|
|
198
280
|
const meta = await metaRes.json().catch(() => null);
|
|
199
281
|
const skin = meta?.skin;
|
|
200
282
|
if (!metaRes.ok || !skin || skin.status && skin.status !== "approved") {
|
|
@@ -202,13 +284,19 @@ export function createGalleryHandlers({ dataRoot, actions }) {
|
|
|
202
284
|
}
|
|
203
285
|
await fsp.mkdir(tmpDir, { recursive: true });
|
|
204
286
|
const imageUrl = skinUrl(center, id, "image");
|
|
205
|
-
const
|
|
287
|
+
const imageTmp = path.join(tmpDir, "image.download");
|
|
206
288
|
write({ phase: "download", part: "image" });
|
|
207
|
-
await downloadToFile(imageUrl,
|
|
289
|
+
const imageDownload = await downloadToFile(imageUrl, imageTmp, {
|
|
208
290
|
maxBytes: MAX_IMAGE_BYTES,
|
|
209
291
|
expectedOrigin: origin,
|
|
292
|
+
signal: clientAbort.signal,
|
|
210
293
|
onProgress: (done, total) => write({ phase: "download", part: "image", done, total }),
|
|
211
294
|
});
|
|
295
|
+
const imagePath = path.join(
|
|
296
|
+
tmpDir,
|
|
297
|
+
`image${extensionForContentType(imageDownload.contentType) ?? extensionOf(imageUrl, ".png")}`,
|
|
298
|
+
);
|
|
299
|
+
await fsp.rename(imageTmp, imagePath);
|
|
212
300
|
let videoPath;
|
|
213
301
|
if (skin.type === "video") {
|
|
214
302
|
const videoUrl = skinUrl(center, id, "video");
|
|
@@ -217,25 +305,34 @@ export function createGalleryHandlers({ dataRoot, actions }) {
|
|
|
217
305
|
await downloadToFile(videoUrl, videoPath, {
|
|
218
306
|
maxBytes: MAX_VIDEO_BYTES,
|
|
219
307
|
expectedOrigin: origin,
|
|
308
|
+
signal: clientAbort.signal,
|
|
220
309
|
onProgress: (done, total) => write({ phase: "download", part: "video", done, total }),
|
|
221
310
|
});
|
|
222
311
|
}
|
|
223
|
-
|
|
312
|
+
// Skin-center media is downloaded into the store, so it is a managed
|
|
313
|
+
// import. importTheme applies-and-saves atomically; the media is copied
|
|
314
|
+
// before it returns, so tmpDir is safe to remove in finally.
|
|
315
|
+
write({ phase: "apply" });
|
|
316
|
+
// The apply/save step is atomic and cannot be interrupted; refuse to
|
|
317
|
+
// start it when the client already went away so a disconnected UI does
|
|
318
|
+
// not still commit and apply a background after the controls recovered.
|
|
319
|
+
if (clientAbort.signal.aborted) throw new Error("客户端已断开。");
|
|
224
320
|
const imported = await importTheme({
|
|
225
|
-
name:
|
|
321
|
+
name: normalizeThemeName(skin.name || id),
|
|
226
322
|
imagePath,
|
|
227
323
|
...(videoPath ? { videoPath } : {}),
|
|
228
324
|
...(skin.effects ? { effects: skin.effects } : {}),
|
|
229
|
-
source:
|
|
325
|
+
source: "managed",
|
|
230
326
|
});
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
327
|
+
fetch(skinUrl(center, id, "download"), {
|
|
328
|
+
method: "POST",
|
|
329
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
330
|
+
}).catch(() => {});
|
|
234
331
|
write({
|
|
235
332
|
ok: true,
|
|
236
333
|
phase: "done",
|
|
237
334
|
theme: imported.theme || imported,
|
|
238
|
-
message:
|
|
335
|
+
message: imported.message || `已安装并应用「${skin.name}」。`,
|
|
239
336
|
});
|
|
240
337
|
} catch (error) {
|
|
241
338
|
write({
|
|
@@ -243,6 +340,7 @@ export function createGalleryHandlers({ dataRoot, actions }) {
|
|
|
243
340
|
error: error instanceof Error ? error.message : String(error),
|
|
244
341
|
});
|
|
245
342
|
} finally {
|
|
343
|
+
res.off("close", onClose);
|
|
246
344
|
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
247
345
|
res.end();
|
|
248
346
|
}
|
package/gallery.js
CHANGED
|
@@ -48,6 +48,10 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
|
|
|
48
48
|
let centerUrl = "";
|
|
49
49
|
let busy = false;
|
|
50
50
|
|
|
51
|
+
// Mirrors gallery-host.mjs::isSafeSkinId — catalog ids are only safe to
|
|
52
|
+
// render when they match the skin-<hex> shape the backend already validates.
|
|
53
|
+
const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
|
|
54
|
+
|
|
51
55
|
function escapeText(value) {
|
|
52
56
|
return String(value ?? "")
|
|
53
57
|
.replaceAll("&", "&")
|
|
@@ -55,6 +59,12 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
|
|
|
55
59
|
.replaceAll(">", ">");
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
// Attribute context additionally needs quote escaping; escapeText alone lets
|
|
63
|
+
// a malicious catalog id break out of data-id/src and inject handlers.
|
|
64
|
+
function escapeAttr(value) {
|
|
65
|
+
return escapeText(value).replaceAll('"', """).replaceAll("'", "'");
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
async function request(path, init) {
|
|
59
69
|
const response = await fetch(path, init);
|
|
60
70
|
if ((response.headers.get("content-type") || "").includes("ndjson")) {
|
|
@@ -98,36 +108,48 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
|
|
|
98
108
|
|
|
99
109
|
async function load() {
|
|
100
110
|
msg.textContent = "正在读取目录…";
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
(skin) =>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
try {
|
|
112
|
+
const params = new URLSearchParams();
|
|
113
|
+
if (queryInput.value.trim()) params.set("q", queryInput.value.trim());
|
|
114
|
+
if (typeSelect.value) params.set("type", typeSelect.value);
|
|
115
|
+
const data = await request(`/__beauticode/ui/gallery/catalog?${params}`);
|
|
116
|
+
centerUrl = data.url || centerUrl;
|
|
117
|
+
grid.innerHTML = (data.skins || [])
|
|
118
|
+
.filter((skin) => SKIN_ID.test(String(skin.id ?? "")))
|
|
119
|
+
.map(
|
|
120
|
+
(skin) =>
|
|
121
|
+
`<button type="button" class="bcg-card" data-id="${escapeAttr(skin.id)}">` +
|
|
122
|
+
`<img alt="" src="${escapeAttr(centerUrl)}/api/skins/${escapeAttr(skin.id)}/card">` +
|
|
123
|
+
`<span>${escapeText(skin.name)}${skin.type === "video" ? " · 视频" : ""}</span></button>`,
|
|
124
|
+
)
|
|
125
|
+
.join("");
|
|
126
|
+
msg.textContent = data.skins?.length ? "" : "目录是空的。";
|
|
127
|
+
foot.innerHTML = centerUrl
|
|
128
|
+
? `上传与审核在 <a href="${escapeAttr(centerUrl)}" target="_blank" rel="noreferrer">皮肤中心网站</a>。安装会下载到本机后再应用。`
|
|
129
|
+
: "未配置皮肤中心地址。在插件的 skin-center.json 或环境变量 BEAUTICODE_SKIN_CENTER 里填入你的域名。";
|
|
130
|
+
} catch (error) {
|
|
131
|
+
grid.innerHTML = "";
|
|
132
|
+
msg.textContent = error instanceof Error ? error.message : String(error);
|
|
133
|
+
}
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
async function open() {
|
|
121
137
|
host.hidden = false;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
try {
|
|
139
|
+
const config = await request("/__beauticode/ui/gallery/config");
|
|
140
|
+
centerUrl = config.url || "";
|
|
141
|
+
if (!config.enabled) {
|
|
142
|
+
grid.innerHTML = "";
|
|
143
|
+
msg.textContent = "尚未配置皮肤中心地址。";
|
|
144
|
+
foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。";
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await load();
|
|
148
|
+
} catch (error) {
|
|
125
149
|
grid.innerHTML = "";
|
|
126
|
-
msg.textContent =
|
|
127
|
-
|
|
128
|
-
return;
|
|
150
|
+
msg.textContent = error instanceof Error ? error.message : String(error);
|
|
151
|
+
throw error;
|
|
129
152
|
}
|
|
130
|
-
await load();
|
|
131
153
|
}
|
|
132
154
|
|
|
133
155
|
function close() {
|
package/host-apply.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
} from "./control-client.mjs";
|
|
8
8
|
import { canvasImagePath } from "./presets.mjs";
|
|
9
9
|
|
|
10
|
-
const sessions = new Map();
|
|
11
|
-
const DSH_VERIFY_DEADLINE_MS = 10_000;
|
|
10
|
+
const sessions = new Map();
|
|
11
|
+
const DSH_VERIFY_DEADLINE_MS = 10_000;
|
|
12
12
|
|
|
13
13
|
export const ENGINE_MISSING_MESSAGE =
|
|
14
14
|
"beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。";
|
|
@@ -93,7 +93,7 @@ export async function ensureInProcessSession(options) {
|
|
|
93
93
|
const session = new adapter.DshSession({
|
|
94
94
|
dataRoot,
|
|
95
95
|
baseUrl,
|
|
96
|
-
verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
|
|
96
|
+
verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
|
|
97
97
|
bundledGalleryImagePath: canvasImagePath() || undefined,
|
|
98
98
|
});
|
|
99
99
|
try {
|
package/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { registerAgentSurfaces } from "./agent.mjs";
|
|
7
7
|
import { resolvePluginBaseUrl } from "./host-apply.mjs";
|
|
8
8
|
import { createBeauticodeUi } from "./ui-host.mjs";
|
|
@@ -12,10 +12,10 @@ export const name = "beauticode-bridge";
|
|
|
12
12
|
export const inject = ["webServer"];
|
|
13
13
|
export const bridgeProtocol = 4;
|
|
14
14
|
|
|
15
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
17
|
-
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
18
|
-
const MAX_BODY_BYTES = 64 * 1024;
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
17
|
+
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
18
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
19
19
|
|
|
20
20
|
async function readBridgeIdentity() {
|
|
21
21
|
try {
|
|
@@ -111,7 +111,7 @@ function isSameOrigin(req) {
|
|
|
111
111
|
return req.headers["sec-fetch-site"] === "same-origin";
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
function validLoopbackMediaUrl(value) {
|
|
114
|
+
function validLoopbackMediaUrl(value) {
|
|
115
115
|
if (typeof value !== "string") return false;
|
|
116
116
|
try {
|
|
117
117
|
const url = new URL(value);
|
|
@@ -123,9 +123,9 @@ function validLoopbackMediaUrl(value) {
|
|
|
123
123
|
} catch {
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function validApplyPayload(body) {
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validApplyPayload(body) {
|
|
129
129
|
if (!Number.isSafeInteger(body.generation) || body.generation < 0) return false;
|
|
130
130
|
if (!["image", "video", "clear"].includes(body.media)) return false;
|
|
131
131
|
if (body.atmosphere != null && !normalizeAtmosphere(body.atmosphere)) return false;
|
|
@@ -173,12 +173,22 @@ function publicStatus(current, modes, clients, clientStates) {
|
|
|
173
173
|
const failedAcks = activeAcks.filter(
|
|
174
174
|
(ack) => ack.ok === false && typeof ack.error === "string" && ack.error,
|
|
175
175
|
);
|
|
176
|
+
const readyAcks = activeAcks.filter((ack) => ack.ok === true);
|
|
177
|
+
// Poster-first video commits ack ok while the first frame still settles.
|
|
178
|
+
const videoPendingAcks = readyAcks.filter(
|
|
179
|
+
(ack) => ack.media === "video" && ack.videoReady === false,
|
|
180
|
+
);
|
|
176
181
|
return {
|
|
177
182
|
ok: true,
|
|
178
183
|
connectedClients: clients.size,
|
|
179
184
|
current,
|
|
180
|
-
readyClients:
|
|
185
|
+
readyClients: readyAcks.length,
|
|
181
186
|
failedClients: failedAcks.length,
|
|
187
|
+
videoReadyClients: readyAcks.length - videoPendingAcks.length,
|
|
188
|
+
videoPendingClients: videoPendingAcks.length,
|
|
189
|
+
lastVideoError:
|
|
190
|
+
videoPendingAcks.find((ack) => typeof ack.error === "string" && ack.error)?.error ??
|
|
191
|
+
null,
|
|
182
192
|
lastRenderError:
|
|
183
193
|
failedAcks.find((ack) => typeof ack.error === "string" && ack.error)?.error ??
|
|
184
194
|
null,
|
|
@@ -192,10 +202,10 @@ function publicStatus(current, modes, clients, clientStates) {
|
|
|
192
202
|
}
|
|
193
203
|
|
|
194
204
|
export function apply(ctx, config = {}) {
|
|
195
|
-
const tokenFile = path.resolve(config.tokenFile || defaultTokenFile());
|
|
196
|
-
const clients = new Map();
|
|
197
|
-
const clientStates = new Map();
|
|
198
|
-
let current = null;
|
|
205
|
+
const tokenFile = path.resolve(config.tokenFile || defaultTokenFile());
|
|
206
|
+
const clients = new Map();
|
|
207
|
+
const clientStates = new Map();
|
|
208
|
+
let current = null;
|
|
199
209
|
const modes = { fish: false, muted: true, tone: "auto" };
|
|
200
210
|
const dataRoot = path.dirname(tokenFile);
|
|
201
211
|
const baseUrl = resolvePluginBaseUrl(ctx);
|
|
@@ -308,9 +318,9 @@ export function apply(ctx, config = {}) {
|
|
|
308
318
|
res.end(req.method === "HEAD" ? undefined : source);
|
|
309
319
|
},
|
|
310
320
|
}),
|
|
311
|
-
ctx.webServer.register({
|
|
312
|
-
kind: "exact",
|
|
313
|
-
path: "/__beauticode/client.js",
|
|
321
|
+
ctx.webServer.register({
|
|
322
|
+
kind: "exact",
|
|
323
|
+
path: "/__beauticode/client.js",
|
|
314
324
|
handler: async (req, res) => {
|
|
315
325
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
316
326
|
res.writeHead(405).end();
|
|
@@ -322,10 +332,10 @@ export function apply(ctx, config = {}) {
|
|
|
322
332
|
"cache-control": "no-store",
|
|
323
333
|
"content-length": source.length,
|
|
324
334
|
});
|
|
325
|
-
res.end(req.method === "HEAD" ? undefined : source);
|
|
326
|
-
},
|
|
327
|
-
}),
|
|
328
|
-
ctx.webServer.register({
|
|
335
|
+
res.end(req.method === "HEAD" ? undefined : source);
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
ctx.webServer.register({
|
|
329
339
|
kind: "exact",
|
|
330
340
|
path: "/__beauticode/gallery.js",
|
|
331
341
|
handler: async (req, res) => {
|
|
@@ -442,8 +452,8 @@ export function apply(ctx, config = {}) {
|
|
|
442
452
|
clients.get(clientId)?.destroy();
|
|
443
453
|
clients.set(clientId, res);
|
|
444
454
|
clientStates.set(clientId, { render: null, mode: null });
|
|
445
|
-
if (current) {
|
|
446
|
-
res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`);
|
|
455
|
+
if (current) {
|
|
456
|
+
res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`);
|
|
447
457
|
}
|
|
448
458
|
res.write(`data: ${JSON.stringify({ type: "mode", ...modes })}\n\n`);
|
|
449
459
|
ui.scheduleRestore();
|
|
@@ -472,18 +482,18 @@ export function apply(ctx, config = {}) {
|
|
|
472
482
|
sendJson(res, 400, { ok: false, error: "背景载荷无效。" });
|
|
473
483
|
return;
|
|
474
484
|
}
|
|
475
|
-
current = {
|
|
476
|
-
generation: body.generation,
|
|
477
|
-
media: body.media,
|
|
478
|
-
imageUrl: body.media === "clear" ? null : body.imageUrl,
|
|
479
|
-
videoUrl: body.media === "video" ? body.videoUrl : null,
|
|
480
|
-
startAt: body.media === "video" ? body.startAt ?? null : null,
|
|
481
|
-
};
|
|
482
|
-
const atmosphere = normalizeAtmosphere(body.atmosphere);
|
|
483
|
-
if (atmosphere) current.atmosphere = atmosphere;
|
|
484
|
-
if (body.media === "clear") modes.fish = false;
|
|
485
|
-
for (const state of clientStates.values()) state.render = null;
|
|
486
|
-
broadcast({ type: "apply", ...current });
|
|
485
|
+
current = {
|
|
486
|
+
generation: body.generation,
|
|
487
|
+
media: body.media,
|
|
488
|
+
imageUrl: body.media === "clear" ? null : body.imageUrl,
|
|
489
|
+
videoUrl: body.media === "video" ? body.videoUrl : null,
|
|
490
|
+
startAt: body.media === "video" ? body.startAt ?? null : null,
|
|
491
|
+
};
|
|
492
|
+
const atmosphere = normalizeAtmosphere(body.atmosphere);
|
|
493
|
+
if (atmosphere) current.atmosphere = atmosphere;
|
|
494
|
+
if (body.media === "clear") modes.fish = false;
|
|
495
|
+
for (const state of clientStates.values()) state.render = null;
|
|
496
|
+
broadcast({ type: "apply", ...current });
|
|
487
497
|
if (body.media === "clear") broadcast({ type: "mode", ...modes });
|
|
488
498
|
sendJson(res, 200, { ok: true });
|
|
489
499
|
},
|
|
@@ -555,7 +565,8 @@ export function apply(ctx, config = {}) {
|
|
|
555
565
|
body.generation !== current.generation ||
|
|
556
566
|
body.media !== current.media ||
|
|
557
567
|
typeof body.ok !== "boolean" ||
|
|
558
|
-
typeof body.visible !== "boolean"
|
|
568
|
+
typeof body.visible !== "boolean" ||
|
|
569
|
+
(body.videoReady != null && typeof body.videoReady !== "boolean")
|
|
559
570
|
) {
|
|
560
571
|
sendJson(res, 400, { ok: false, error: "渲染回执无效。" });
|
|
561
572
|
return;
|
|
@@ -587,6 +598,9 @@ export function apply(ctx, config = {}) {
|
|
|
587
598
|
ok: body.ok,
|
|
588
599
|
visible: body.visible,
|
|
589
600
|
error: typeof body.error === "string" ? body.error.slice(0, 300) : null,
|
|
601
|
+
// Poster-first commits ack ok with videoReady=false while the
|
|
602
|
+
// first frame still settles; absent means a legacy ready client.
|
|
603
|
+
videoReady: body.media !== "video" || body.videoReady !== false,
|
|
590
604
|
playback,
|
|
591
605
|
};
|
|
592
606
|
} else if (body.kind === "mode") {
|
|
@@ -621,10 +635,10 @@ export function apply(ctx, config = {}) {
|
|
|
621
635
|
return () => {
|
|
622
636
|
disposeTap();
|
|
623
637
|
for (const dispose of disposers) dispose();
|
|
624
|
-
for (const response of clients.values()) response.destroy();
|
|
625
|
-
clients.clear();
|
|
626
|
-
clientStates.clear();
|
|
627
|
-
void ui.dispose();
|
|
638
|
+
for (const response of clients.values()) response.destroy();
|
|
639
|
+
clients.clear();
|
|
640
|
+
clientStates.clear();
|
|
641
|
+
void ui.dispose();
|
|
628
642
|
};
|
|
629
643
|
}, "beauticode-bridge: routes and browser injection");
|
|
630
644
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Internal / Infernal atmosphere assets
|
|
2
|
-
|
|
3
|
-
`bg-internal.jpg`, `bg-infernal.jpg`, `bg-canvas.png`, and the local
|
|
4
|
-
`bg-canvas-4k.png` (Real-ESRGAN 4x then 3840×2160) are visual materials
|
|
5
|
-
from [Internal Beyond](https://github.com/Sui-IB/InternalBeyond) by Sui.
|
|
6
|
-
|
|
7
|
-
They remain under that project's CC BY-NC-SA 4.0 terms for visual assets.
|
|
8
|
-
BeautiCode ships them as bundled workspace themes for local, non-commercial use.
|
|
9
|
-
The project name, logo, and character identity are not licensed here.
|
|
1
|
+
# Internal / Infernal atmosphere assets
|
|
2
|
+
|
|
3
|
+
`bg-internal.jpg`, `bg-infernal.jpg`, `bg-canvas.png`, and the local
|
|
4
|
+
`bg-canvas-4k.png` (Real-ESRGAN 4x then 3840×2160) are visual materials
|
|
5
|
+
from [Internal Beyond](https://github.com/Sui-IB/InternalBeyond) by Sui.
|
|
6
|
+
|
|
7
|
+
They remain under that project's CC BY-NC-SA 4.0 terms for visual assets.
|
|
8
|
+
BeautiCode ships them as bundled workspace themes for local, non-commercial use.
|
|
9
|
+
The project name, logo, and character identity are not licensed here.
|
package/ui-host.mjs
CHANGED
|
@@ -20,6 +20,10 @@ const NATIVE_PICKER_UNAVAILABLE = "native_picker_unavailable";
|
|
|
20
20
|
const NATIVE_PICKER_REQUIRED = "native_picker_required";
|
|
21
21
|
const PICKER_REQUEST_ABORTED = "picker_request_aborted";
|
|
22
22
|
const IMPORT_TIMING_LOG = "import-timing.jsonl";
|
|
23
|
+
// /reapply runs a full apply transaction (import + stage + verify + commit),
|
|
24
|
+
// which has no total deadline of its own; give it headroom beyond the 30s that
|
|
25
|
+
// the two-verify video path can already approach (~26s).
|
|
26
|
+
const REAPPLY_TIMEOUT_MS = 60_000;
|
|
23
27
|
|
|
24
28
|
function elapsedMs(startedAt) {
|
|
25
29
|
return Math.round((performance.now() - startedAt) * 10) / 10;
|
|
@@ -369,7 +373,7 @@ export function createBeauticodeUi({
|
|
|
369
373
|
method: "POST",
|
|
370
374
|
path: "/reapply",
|
|
371
375
|
body: {},
|
|
372
|
-
timeoutMs:
|
|
376
|
+
timeoutMs: REAPPLY_TIMEOUT_MS,
|
|
373
377
|
});
|
|
374
378
|
}
|
|
375
379
|
if (!(await canReachBridge(options.baseUrl))) return;
|
|
@@ -379,7 +383,7 @@ export function createBeauticodeUi({
|
|
|
379
383
|
method: "POST",
|
|
380
384
|
path: "/reapply",
|
|
381
385
|
body: {},
|
|
382
|
-
timeoutMs:
|
|
386
|
+
timeoutMs: REAPPLY_TIMEOUT_MS,
|
|
383
387
|
});
|
|
384
388
|
}
|
|
385
389
|
return resolved.session.reapply();
|
|
@@ -145,7 +145,33 @@ export class DshSession {
|
|
|
145
145
|
const result = await tx.run(input, themeName
|
|
146
146
|
? {
|
|
147
147
|
beforeFinalize: async () => {
|
|
148
|
-
|
|
148
|
+
// Persist the initial seek so a restore before the periodic
|
|
149
|
+
// progress writer runs still resumes at the requested position.
|
|
150
|
+
let videoPositionSec = null;
|
|
151
|
+
if (input.type === "video") {
|
|
152
|
+
if (this.host) {
|
|
153
|
+
try {
|
|
154
|
+
const position = await this.host.getPlaybackPosition();
|
|
155
|
+
if (position.ok &&
|
|
156
|
+
position.hasVideo &&
|
|
157
|
+
Number.isFinite(position.currentTime)) {
|
|
158
|
+
videoPositionSec = position.currentTime;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* fall back to input.startAt below */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const startAt = typeof input.startAt === "number" && Number.isFinite(input.startAt)
|
|
166
|
+
? input.startAt
|
|
167
|
+
: null;
|
|
168
|
+
if (videoPositionSec == null && startAt != null) {
|
|
169
|
+
videoPositionSec = Math.max(0, startAt);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
saved.theme = await this.store.saveCurrentTheme(themeName, {
|
|
173
|
+
...(videoPositionSec != null ? { videoPositionSec } : {}),
|
|
174
|
+
});
|
|
149
175
|
},
|
|
150
176
|
onRollback: async () => {
|
|
151
177
|
if (saved.theme) {
|
|
@@ -155,7 +181,10 @@ export class DshSession {
|
|
|
155
181
|
}
|
|
156
182
|
: undefined);
|
|
157
183
|
if (result.ok) {
|
|
158
|
-
|
|
184
|
+
// Retain the saved theme id for image themes too: status.themeId marks
|
|
185
|
+
// the current selection in the console, and video-position tracking is
|
|
186
|
+
// already gated on hasVideo / "Not a video theme" downstream.
|
|
187
|
+
this.activeThemeId = saved.theme?.id ?? null;
|
|
159
188
|
this.lastProgressWriteSec = -1;
|
|
160
189
|
if (input.type === "clear") {
|
|
161
190
|
this.fishMode = false;
|
|
@@ -297,6 +326,7 @@ export class DshSession {
|
|
|
297
326
|
fish: this.fishMode,
|
|
298
327
|
muted: this.videoMuted,
|
|
299
328
|
tone: this.backgroundTone,
|
|
329
|
+
themeId: this.activeThemeId,
|
|
300
330
|
};
|
|
301
331
|
}
|
|
302
332
|
async saveCurrentTheme(name) {
|
|
@@ -313,13 +343,8 @@ export class DshSession {
|
|
|
313
343
|
}
|
|
314
344
|
}
|
|
315
345
|
const theme = await this.store.saveCurrentTheme(name, { videoPositionSec });
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
this.lastProgressWriteSec = -1;
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
this.activeThemeId = null;
|
|
322
|
-
}
|
|
346
|
+
this.activeThemeId = theme.id;
|
|
347
|
+
this.lastProgressWriteSec = -1;
|
|
323
348
|
return theme;
|
|
324
349
|
}
|
|
325
350
|
async listSavedThemes() {
|
|
@@ -338,7 +363,7 @@ export class DshSession {
|
|
|
338
363
|
const saved = await this.store.loadSavedTheme(themeId);
|
|
339
364
|
const result = await this.apply(saved.input);
|
|
340
365
|
if (result.ok) {
|
|
341
|
-
this.activeThemeId = saved.
|
|
366
|
+
this.activeThemeId = saved.themeId;
|
|
342
367
|
this.lastProgressWriteSec = -1;
|
|
343
368
|
}
|
|
344
369
|
return result;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { MAX_INLINE_DATA_URL_BYTES } from "./constants.js";
|
|
3
|
+
import { MAX_INLINE_DATA_URL_BYTES, MAX_VIDEO_POSTER_INLINE_BYTES, } from "./constants.js";
|
|
4
4
|
import { isLocalBackgroundSource, resolveBackgroundImagePath, resolveBackgroundVideoPath, } from "./media-source.js";
|
|
5
5
|
import { detectImageMime } from "./media-validation.js";
|
|
6
6
|
// Cold-first-access symptoms reported by the renderer while the OS is still
|
|
@@ -286,7 +286,13 @@ export async function buildHostApplyPayload(store, manifest, staged, cssText, vi
|
|
|
286
286
|
const imagePath = resolveBackgroundImagePath(store.paths.activeDir, manifest.background);
|
|
287
287
|
if (!imagePath)
|
|
288
288
|
throw new Error("Background has no image source.");
|
|
289
|
-
const
|
|
289
|
+
const imageStat = await fs.stat(imagePath).catch(() => null);
|
|
290
|
+
const skipInlinePoster = manifest.background.type === "video" &&
|
|
291
|
+
imageStat != null &&
|
|
292
|
+
imageStat.size > MAX_VIDEO_POSTER_INLINE_BYTES;
|
|
293
|
+
const imageDataUrl = opts.includeImageDataUrl === false || skipInlinePoster
|
|
294
|
+
? null
|
|
295
|
+
: await fileToDataUrl(imagePath);
|
|
290
296
|
const imageUrl = staged?.image?.srcUrl ?? null;
|
|
291
297
|
if (manifest.background.type === "image") {
|
|
292
298
|
return {
|
package/vendor/core/constants.js
CHANGED
|
@@ -9,6 +9,11 @@ export const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
|
|
|
9
9
|
* Keep this equal to MAX_IMAGE_BYTES so every accepted image can publish.
|
|
10
10
|
*/
|
|
11
11
|
export const MAX_INLINE_DATA_URL_BYTES = MAX_IMAGE_BYTES;
|
|
12
|
+
/**
|
|
13
|
+
* Keep a large video poster out of the Codex Runtime.evaluate payload.
|
|
14
|
+
* The first decoded video frame replaces the poster after the blob attaches.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_VIDEO_POSTER_INLINE_BYTES = 1024 * 1024;
|
|
12
17
|
export const COMMIT_MARKER_NAME = ".beauticode-commit-in-progress";
|
|
13
18
|
export const COMMIT_MARKER_STALE_MS = 120_000;
|
|
14
19
|
export const MEDIA_TOKEN_HEADER = "x-beauticode-media-token";
|
package/vendor/core/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { assertSafeBasename } from "./media-validation.js";
|
|
2
3
|
/** Resolve a manifest source against the directory that owns managed files. */
|
|
3
4
|
export function resolveMediaSource(source, ownerDir) {
|
|
4
5
|
if (source.kind === "local")
|
|
5
6
|
return path.resolve(source.path);
|
|
7
|
+
assertSafeBasename(source.file, "background.source.file");
|
|
6
8
|
return path.join(ownerDir, source.file);
|
|
7
9
|
}
|
|
8
10
|
/** Resolve the primary image source, including legacy v1 manifests. */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* How close a process's actual start time must be to the recorded start time
|
|
4
|
+
* before we treat them as the same process. PID reuse yields a completely
|
|
5
|
+
* different start time, so a generous window is safe while still rejecting
|
|
6
|
+
* recycled PIDs.
|
|
7
|
+
*/
|
|
8
|
+
const START_TIME_TOLERANCE_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* True when `pid` refers to a process that still exists. Mirrors the PID check
|
|
11
|
+
* callers perform before consulting a recorded process identity.
|
|
12
|
+
*/
|
|
13
|
+
function isPidAlive(pid) {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return (Boolean(error) &&
|
|
20
|
+
typeof error === "object" &&
|
|
21
|
+
error.code === "EPERM");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the actual process start time as an epoch millisecond value.
|
|
26
|
+
* Returns null when the process does not exist or the platform helper fails.
|
|
27
|
+
*/
|
|
28
|
+
function processStartTimeMs(pid) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
if (process.platform === "win32") {
|
|
31
|
+
const script = `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`;
|
|
32
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3_000, windowsHide: true }, (error, stdout) => {
|
|
33
|
+
if (error)
|
|
34
|
+
return resolve(null);
|
|
35
|
+
const value = Date.parse(String(stdout).trim());
|
|
36
|
+
resolve(Number.isFinite(value) ? value : null);
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Linux / macOS: ps -o lstart= prints the start time in local time with no
|
|
41
|
+
// timezone suffix, which Date.parse interprets as the host's local time.
|
|
42
|
+
execFile("ps", ["-o", "lstart=", "-p", String(pid)], { timeout: 3_000 }, (error, stdout) => {
|
|
43
|
+
if (error)
|
|
44
|
+
return resolve(null);
|
|
45
|
+
const value = Date.parse(String(stdout).trim());
|
|
46
|
+
resolve(Number.isFinite(value) ? value : null);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when `pid` is alive AND its real start time matches `recordedIso` (the
|
|
52
|
+
* startedAt value a control/claim file captured when the process launched).
|
|
53
|
+
*
|
|
54
|
+
* Falls back to optimistic `true` when there is no recorded timestamp or the
|
|
55
|
+
* platform helper is unavailable, so an unverifiable check never marks a live
|
|
56
|
+
* tray/session as dead. Returns `false` when the PID no longer exists or its
|
|
57
|
+
* start time clearly differs (PID reuse).
|
|
58
|
+
*/
|
|
59
|
+
export async function isRecordedPidLive(pid, recordedIso) {
|
|
60
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
61
|
+
return false;
|
|
62
|
+
if (!isPidAlive(pid))
|
|
63
|
+
return false;
|
|
64
|
+
if (typeof recordedIso !== "string" || !recordedIso)
|
|
65
|
+
return true;
|
|
66
|
+
const recordedMs = Date.parse(recordedIso);
|
|
67
|
+
if (!Number.isFinite(recordedMs))
|
|
68
|
+
return true;
|
|
69
|
+
let actualMs;
|
|
70
|
+
try {
|
|
71
|
+
actualMs = await processStartTimeMs(pid);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
actualMs = null;
|
|
75
|
+
}
|
|
76
|
+
// No helper result means "cannot verify" — keep the optimistic PID check.
|
|
77
|
+
if (actualMs == null)
|
|
78
|
+
return true;
|
|
79
|
+
return Math.abs(actualMs - recordedMs) <= START_TIME_TOLERANCE_MS;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=process-liveness.js.map
|