beauticode-dsh 1.0.22 → 1.0.24
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/LICENSE +21 -21
- package/agent.mjs +47 -27
- package/atmosphere.js +3 -2
- package/cli.js +15 -3
- package/client.js +8 -2
- package/console.js +34 -5
- package/gallery-host.mjs +114 -16
- package/gallery.js +46 -24
- package/host-apply.mjs +3 -3
- 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/index.js +1 -0
- package/vendor/core/media-source.js +2 -0
- package/vendor/core/process-liveness.js +81 -0
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 beautiCode contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 beautiCode contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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,3 +1,4 @@
|
|
|
1
|
+
//hello,starstreaming
|
|
1
2
|
(() => {
|
|
2
3
|
"use strict";
|
|
3
4
|
|
|
@@ -92,8 +93,8 @@ html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{
|
|
|
92
93
|
}
|
|
93
94
|
html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important}
|
|
94
95
|
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}
|
|
96
|
+
html[data-bc-gallery="true"] #beauticode-bg-stage img,
|
|
97
|
+
html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
|
|
97
98
|
html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
|
|
98
99
|
`;
|
|
99
100
|
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
|
@@ -1543,7 +1543,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1543
1543
|
activePayload === committedPayload &&
|
|
1544
1544
|
renderPhase === "ready"
|
|
1545
1545
|
) {
|
|
1546
|
-
await acknowledgeRender(committedPayload, true, true
|
|
1546
|
+
await acknowledgeRender(committedPayload, true, true, null, {
|
|
1547
|
+
videoReady: mountedCurrentSlot()?.dataset.bcVideoReady === "true",
|
|
1548
|
+
});
|
|
1547
1549
|
}
|
|
1548
1550
|
} else {
|
|
1549
1551
|
playbackBlocked = false;
|
|
@@ -1598,7 +1600,11 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1598
1600
|
// A heartbeat is observational, not a second render verdict. Playback can
|
|
1599
1601
|
// briefly pause while Chromium changes modes or refills an 8K buffer; do
|
|
1600
1602
|
// not downgrade an already-rendered generation or fail a pending one.
|
|
1601
|
-
|
|
1603
|
+
// Report the real first-frame state: a committed video that still only
|
|
1604
|
+
// shows its poster must not be acked as video-ready (see settleCommittedVideo).
|
|
1605
|
+
void acknowledgeRender(committedPayload, true, true, null, {
|
|
1606
|
+
videoReady: slot.dataset.bcVideoReady === "true",
|
|
1607
|
+
}).catch(() => {});
|
|
1602
1608
|
}
|
|
1603
1609
|
}, 1_000);
|
|
1604
1610
|
})();
|
package/console.js
CHANGED
|
@@ -126,14 +126,36 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
|
|
|
126
126
|
pop.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
function isContents(node) {
|
|
130
|
+
if (!node) return false;
|
|
131
|
+
if (node.style?.display === "contents") return true;
|
|
132
|
+
if (typeof getComputedStyle === "function") {
|
|
133
|
+
try {
|
|
134
|
+
return getComputedStyle(node).display === "contents";
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function layoutParent(node) {
|
|
143
|
+
let current = node?.parentElement ?? null;
|
|
144
|
+
while (current && isContents(current)) current = current.parentElement;
|
|
145
|
+
return current;
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
function place() {
|
|
130
149
|
const settings = findSettingsTrigger();
|
|
131
|
-
|
|
150
|
+
const row = layoutParent(settings);
|
|
151
|
+
const settingsArea = layoutParent(row);
|
|
152
|
+
const footArea = layoutParent(settingsArea);
|
|
153
|
+
if (!settings || !settingsArea || !footArea) {
|
|
132
154
|
if (host.parentElement) host.remove();
|
|
133
155
|
return;
|
|
134
156
|
}
|
|
135
|
-
if (host.parentElement !==
|
|
136
|
-
|
|
157
|
+
if (host.parentElement !== footArea || host.nextElementSibling !== settingsArea) {
|
|
158
|
+
footArea.insertBefore(host, settingsArea);
|
|
137
159
|
}
|
|
138
160
|
host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40);
|
|
139
161
|
if (!pop.hidden) placePop();
|
|
@@ -252,7 +274,12 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
|
|
|
252
274
|
return body;
|
|
253
275
|
} catch (error) {
|
|
254
276
|
if (controller?.signal.aborted) {
|
|
255
|
-
|
|
277
|
+
// The browser only cancels its own fetch; a slow backend transaction
|
|
278
|
+
// (managed-video copy or verify) may still commit afterwards. Don't
|
|
279
|
+
// claim the previous background was preserved — report honestly.
|
|
280
|
+
throw new Error(
|
|
281
|
+
"背景操作超时,控件已恢复。操作可能仍在后台进行,请先查看当前背景状态再重试。",
|
|
282
|
+
);
|
|
256
283
|
}
|
|
257
284
|
throw error;
|
|
258
285
|
} finally {
|
|
@@ -427,7 +454,9 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
|
|
|
427
454
|
event.stopPropagation();
|
|
428
455
|
setOpen(false);
|
|
429
456
|
if (window.BeauticodeGallery) {
|
|
430
|
-
|
|
457
|
+
window.BeauticodeGallery.open().catch((error) => {
|
|
458
|
+
showMessage(error instanceof Error ? error.message : String(error));
|
|
459
|
+
});
|
|
431
460
|
return;
|
|
432
461
|
}
|
|
433
462
|
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/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;
|
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
|