dsh-folia 3.9.0 → 4.0.0

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/lib/index.js CHANGED
@@ -78,6 +78,10 @@ import { assertSubpackagesResolvable, assertSubpackagesResolvableInHost, httpVie
78
78
  import { createHostSession } from './kernel/host-session.js';
79
79
  export { extOf };
80
80
  import { registerInventoryRoutes } from './routes/inventory.js';
81
+ import { registerModulesRoutes } from './routes/modules.js';
82
+ import { registerBootRoutes } from './routes/boot.js';
83
+ import { registerSiteThumbRoutes } from './routes/site-thumb.js';
84
+ import { installBootInjection } from './boot/index.js';
81
85
  import { registerDiagRoutes } from './routes/diag.js';
82
86
  // 遮挡消息桥族(上游新增域模块):hub 处理 x11 遮挡探针,路由对外暴露状态/控制面。
83
87
  import { createOcclusionHub } from './occlusion/index.js';
@@ -94,7 +98,6 @@ import { registerPropsRoutes } from './routes/props.js';
94
98
  import { registerLiveFrameRoutes } from './routes/live-frame.js';
95
99
  import { registerSettingsRoutes } from './routes/settings.js';
96
100
  import { registerCacheDirRoutes } from './routes/cache-dir.js';
97
- import { registerSkinRoutes, harnessRoot as foliaHarnessRoot } from './routes/skins.js';
98
101
  // C8′/C6 出户的组件行(P2-1 / P2-2):组件入口**静态** import(不是动态)—— `test/verify-reachability.mjs`
99
102
  // 只从 ENTRY 沿 import 边走,动态加载的组件入口会被判定为不可达,棘轮当场变红。
100
103
  // 组件名 → 实现表的登记在 `applyComponent` **函数体内**(P2-1b 起;模块级常量会让门面求值期
@@ -294,6 +297,17 @@ export function apply(ctx) {
294
297
  registerInventoryRoutes(webServer, {
295
298
  disposers, base: BASE, mediaOriginApi, buildInventory, componentsProbe: componentProbe,
296
299
  });
300
+ // 数据模块索引(站点侧声明「有哪些数据模块」)→ lib/routes/modules.js。
301
+ // 与 inventory 同一套站点基址;读不到就退回内置集合,绝不让面板因为索引文件而空白。
302
+ registerModulesRoutes(webServer, { disposers, base: BASE });
303
+
304
+ // 开机动画 → lib/routes/boot.js(HTTP 面)+ lib/boot/index.js(注入与屏幕脚本)。
305
+ // 注入是「关就一行都不注」:没有选片时不产生任何脚本、不产生任何请求。
306
+ registerBootRoutes(webServer, { disposers, base: BASE, log });
307
+
308
+ // 站点图片缩略图代理 → lib/routes/site-thumb.js。卡片用 320px 小图,不再每张下 500 KB 原图。
309
+ registerSiteThumbRoutes(webServer, { disposers, base: BASE, ensureImagePreview, log });
310
+ installBootInjection(ctx, (m) => { try { log(m); } catch { /* ignore */ } });
297
311
 
298
312
  // 3./3a. 派生媒体族路由(源元信息 / 抽帧转码 + 进度 / 视频缩略图)→ lib/routes/media-derived.js。
299
313
  // 四条注册与它们的依赖都在那个文件里;这里只声明它的依赖。**调用点必须早于下面的
@@ -496,21 +510,6 @@ export function apply(ctx) {
496
510
  normalizeUserDir, cacheBaseDir, setCacheDir, armBodyIdleTimeout,
497
511
  });
498
512
 
499
- // 7c. 「皮肤包」数据源(自建站)→ lib/routes/skins.js。一条 prefix 注册带五个动作
500
- // (索引 / 安装 / 激活 / 卸载 / 预览图代理)。
501
- // ⚠️ 渲染不在这里:落到 `harness/skins/<id>/` 之后,应用样式与 hooks 的仍是
502
- // 皮肤中心(dsh-skin-center)。本族只负责"取、验、落、切"。
503
- registerSkinRoutes(webServer, {
504
- disposers, base: BASE, log,
505
- });
506
-
507
- // 皮肤中心自动挂载(可选,默认关闭)。它是标准 cordis 插件(name / inject / apply),
508
- // 所以能被动态挂上 —— 用户不必再装 @linxin666/dsh-web-all 才有皮肤渲染能力。
509
- // 默认关闭的原因:DSH 对"同一插件挂两次"没有保护,我们踩过(重复行 → 安全模式)。
510
- try { maybeMountSkinCenter(ctx, log); } catch (err) {
511
- try { log('[folia] 皮肤中心自动挂载失败(忽略):' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
512
- }
513
-
514
513
  // fiber disposer 的语义(先逐个 disposer、再 releaseKernelLease(hostKernel)——内核引用计数
515
514
  // 归零才 `transcodeKit.dispose()`——再 destroy 在途流、清 activeStreams / mediaMap)
516
515
  // 逐字搬进 lib/kernel/host-session.js 的 `dispose()`。
@@ -594,72 +593,6 @@ function componentProbe() {
594
593
  * —— 那条纪律写在组件入口文件头,并由 `test/verify-bundle-rows.mjs` 的判据钉住;彻底解耦(拆环)
595
594
  * 留给 P2-2b。
596
595
  */
597
- /**
598
- * 是否已经有别的 bundle 把皮肤中心挂上了(默认假设"有",避免重复挂载)。
599
- *
600
- * 判据落在磁盘上的 patch 行:从 harness 根出发,扫每个 profile 的 package.json
601
- * dsh.profile.bundles,把每个 bundle 包的 cordis.patch.yml 读出来,看有没有行指向
602
- * @linxin666/dsh-client-ui-skin-center。有 ⇒ 别人挂了,我们不插手。
603
- * 读不到 / 结构不认识 ⇒ 同样按"已挂载"处理(宁可少挂一次,也不能挂两次)。
604
- */
605
- function skinCenterPatchedElsewhere(harnessRoot) {
606
- try {
607
- const { readdirSync, readFileSync, existsSync } = require('node:fs');
608
- const { join } = require('node:path');
609
- const profilesDir = join(harnessRoot, 'profiles');
610
- if (!existsSync(profilesDir)) return true;
611
- const SKIN_CENTER = '@linxin666/dsh-client-ui-skin-center';
612
- for (const name of readdirSync(profilesDir)) {
613
- if (name.startsWith('.')) continue;
614
- const pkgPath = join(profilesDir, name, 'package.json');
615
- if (!existsSync(pkgPath)) continue;
616
- let prof;
617
- try { prof = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { continue; }
618
- const bundles = (prof && prof.dsh && prof.dsh.profile && prof.dsh.profile.bundles) || [];
619
- for (const b of bundles) {
620
- const patchPath = join(profilesDir, name, 'node_modules', b, 'cordis.patch.yml');
621
- if (!existsSync(patchPath)) continue;
622
- let text = '';
623
- try { text = readFileSync(patchPath, 'utf8'); } catch { continue; }
624
- if (text.includes(SKIN_CENTER)) return true;
625
- }
626
- }
627
- return false;
628
- } catch {
629
- return true;
630
- }
631
- }
632
-
633
- /**
634
- * 按需动态挂载皮肤中心。默认关闭,用 DSH_FOLIA_MOUNT_SKIN_CENTER=1 开启。
635
- * 开启后仍先查"别人挂了吗",已挂则跳过 —— 重复挂载是能让 DSH 起不来的事故。
636
- */
637
- function maybeMountSkinCenter(ctx, log) {
638
- const flag = String(process.env.DSH_FOLIA_MOUNT_SKIN_CENTER || '').trim().toLowerCase();
639
- if (flag !== '1' && flag !== 'on' && flag !== 'true') return;
640
-
641
- const harness = (() => { try { return foliaHarnessRoot(); } catch { return null; } })();
642
- if (!harness) {
643
- try { log('[folia] 皮肤中心自动挂载跳过:找不到 harness 根目录'); } catch { /* ignore */ }
644
- return;
645
- }
646
- if (skinCenterPatchedElsewhere(harness)) {
647
- try { log('[folia] 皮肤中心已由其它 bundle 挂载,跳过自动挂载'); } catch { /* ignore */ }
648
- return;
649
- }
650
- import('@linxin666/dsh-client-ui-skin-center').then((mod) => {
651
- try {
652
- if (!mod || typeof mod.apply !== 'function') { log('[folia] 皮肤中心模块形态不对,跳过'); return; }
653
- ctx.plugin(mod);
654
- log('[folia] 已自动挂载皮肤中心(DSH_FOLIA_MOUNT_SKIN_CENTER=1)');
655
- } catch (err) {
656
- try { log('[folia] 自动挂载皮肤中心失败:' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
657
- }
658
- }).catch((err) => {
659
- try { log('[folia] 找不到皮肤中心(可选依赖),跳过:' + (err && err.message ? err.message : err)); } catch { /* ignore */ }
660
- });
661
- }
662
-
663
596
  export function applyComponent(root, rawConfig, component) {
664
597
  const impls = componentImpls();
665
598
  const impl = impls.get(component);
@@ -0,0 +1,388 @@
1
+ /**
2
+ * routes/boot.js — 开机动画的 HTTP 面:列候选 / 下载 / 选择 / 删除 / 流式播放。
3
+ *
4
+ * 数据来自两处:
5
+ * · **候选清单** = 模块索引里的 `boot` 模块(`manifest` 指向一份 `{ clips: [...] }`),
6
+ * 站点优先、R2 兜底 —— 复用 `routes/modules.js` 的解析,不另造一套基址。
7
+ * · **本地文件** = `~/.dsh-folia/boot/<id>.mp4`,由「下载」写进来。
8
+ *
9
+ * 播放走 `/folia/boot/clip`,**支持 Range**:注入的 `<video>` 与浏览器都靠它边下边播。
10
+ * 这也是为什么片段必须先落盘 —— 启动路径上不能有网络。
11
+ */
12
+ import { createReadStream, existsSync, statSync, writeFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { sendJson } from '../json-response.js';
15
+ import { bodyReader } from '../http-body.js';
16
+ import { resolveModules } from './modules.js';
17
+ import { bootDir, clipExists, clipFile, listLocalClips, readBoot, removeClip, writeBoot } from '../boot/index.js';
18
+
19
+ /**
20
+ * 站点基址。开机动画的候选**从站点分类取**(`?category=boot-animation`),
21
+ * 与壁纸页签用的是同一个接口 —— 站点上看到 3 条,这里就是 3 条,不会各说各话。
22
+ */
23
+ const DEFAULT_SITE = 'https://folia-5ve.pages.dev';
24
+ const BOOT_CATEGORY = 'boot-animation';
25
+ function siteBase() {
26
+ const raw = process.env.DSH_FOLIA_SITE;
27
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
28
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_SITE;
29
+ return base.replace(/\/+$/, '');
30
+ }
31
+
32
+ const CLIP_MAX_BYTES = 64 * 1024 * 1024;
33
+ const FETCH_TIMEOUT_MS = 60000;
34
+
35
+ /**
36
+ * 读完一个小 JSON 请求体。
37
+ *
38
+ * ⚠️ `bodyReader()` 是**流式**收体器(返回 `{ onData, text() }`),不是 Promise ——
39
+ * 必须自己把 `req` 的 data/end 挂上去。把它 `await` 出来会拿到那个对象,
40
+ * `JSON.parse` 立刻抛、被 catch 成 null,于是每个写操作都报 `bad-id`。
41
+ */
42
+ function readBodyText(req, maxBytes) {
43
+ return new Promise((resolve) => {
44
+ let tooLarge = false;
45
+ const reader = bodyReader(req, { maxBytes, onOverflow: () => { tooLarge = true; } });
46
+ const finish = () => resolve(tooLarge ? '' : reader.text());
47
+ try {
48
+ req.on('data', reader.onData);
49
+ req.on('end', finish);
50
+ req.on('error', () => resolve(''));
51
+ } catch {
52
+ resolve('');
53
+ }
54
+ });
55
+ }
56
+
57
+ /**
58
+ * 并行分块下载。
59
+ *
60
+ * 为什么要它:单连接下载 8~50 MB 的片子,在跨境线路上经常只有 200~400 KB/s,
61
+ * 一条连接就是那么慢;而站点(R2 前面的代理)**支持 Range**(实测 206 +
62
+ * accept-ranges: bytes)。把文件切成 N 段并发拉,能把单连接的带宽上限绕过去。
63
+ *
64
+ * 策略:先探一段 `bytes=0-0` 读 `content-range` 拿总长;能并行就并行,不能就退回
65
+ * 单次 GET。任何一段失败都退回单次 GET(宁可慢,不可错)。
66
+ */
67
+ async function fetchClipBytes(url, chunks = 4) {
68
+ const single = async () => {
69
+ const r = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
70
+ if (!r.ok) throw new Error('http-' + r.status);
71
+ return Buffer.from(await r.arrayBuffer());
72
+ };
73
+
74
+ // 探测:只要第 0 字节,顺便从 content-range 里读出总长
75
+ let total = 0;
76
+ try {
77
+ const probe = await fetch(url, { headers: { range: 'bytes=0-0' }, signal: AbortSignal.timeout(15000) });
78
+ if (probe.status === 206) {
79
+ const cr = String(probe.headers.get('content-range') || '');
80
+ const m = /\/(\d+)$/.exec(cr);
81
+ if (m) total = Number(m[1]);
82
+ // 这一字节不用留
83
+ await probe.arrayBuffer().catch(() => {});
84
+ } else {
85
+ await probe.arrayBuffer().catch(() => {});
86
+ }
87
+ } catch { total = 0; }
88
+
89
+ if (!total || total < 2 * 1024 * 1024) return single(); // 小文件并行没意义
90
+
91
+ const n = Math.max(2, Math.min(chunks, 6));
92
+ const part = Math.ceil(total / n);
93
+ try {
94
+ const bufs = await Promise.all(Array.from({ length: n }, (_, i) => {
95
+ const start = i * part;
96
+ const end = Math.min(total - 1, start + part - 1);
97
+ return fetch(url, {
98
+ headers: { range: 'bytes=' + start + '-' + end },
99
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
100
+ }).then(async (r) => {
101
+ if (r.status !== 206) throw new Error('range-' + r.status);
102
+ const b = Buffer.from(await r.arrayBuffer());
103
+ if (b.length !== end - start + 1) throw new Error('short-chunk');
104
+ return b;
105
+ });
106
+ }));
107
+ const joined = Buffer.concat(bufs);
108
+ if (joined.length !== total) return single(); // 长度对不上:别赌,重来
109
+ return joined;
110
+ } catch {
111
+ return single();
112
+ }
113
+ }
114
+
115
+ /** 读请求体里的 `{ id }`。 */
116
+ async function readIdBody(req) {
117
+ try {
118
+ const text = await readBodyText(req, 64 * 1024);
119
+ const doc = JSON.parse(text || '{}');
120
+ const id = String((doc && doc.id) || '').trim();
121
+ return /^[a-z0-9][a-z0-9._-]*$/i.test(id) ? id : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 候选清单:模块索引里的 `boot` 模块 → 拉它的 manifest → `{ clips: [...] }`。
129
+ * 失败一律返回空数组(面板显示"没有候选"),绝不抛。
130
+ */
131
+ async function fetchCandidates() {
132
+ // ① 站点分类接口(与壁纸同一个接口,只是 category 不同)
133
+ const site = siteBase();
134
+ if (site) {
135
+ try {
136
+ const res = await fetch(site + '/api/public/wallpapers?category=' + BOOT_CATEGORY, {
137
+ headers: { accept: 'application/json' },
138
+ signal: AbortSignal.timeout(15000),
139
+ });
140
+ if (res.ok) {
141
+ const doc = await res.json();
142
+ const items = (doc && doc.data && Array.isArray(doc.data.items)) ? doc.data.items : [];
143
+ const clips = items.map((it) => ({
144
+ id: String((it && (it.slug || it.id)) || ''),
145
+ title: String((it && it.title) || (it && it.slug) || ''),
146
+ // 站点接口给的是相对路径(/media/...),下载时要绝对地址 —— 这里补上,
147
+ // 否则 fetch 一个相对路径会当场失败。
148
+ url: (() => { const u = String((it && it.url) || ''); return /^https?:/i.test(u) ? u : (u ? site + u : ''); })(),
149
+ bytes: Number(it && it.bytes) || null,
150
+ sha256: it && it.sha256 ? String(it.sha256) : null,
151
+ durationSec: it && it.durationMs ? Number(it.durationMs) / 1000 : null,
152
+ faststart: true,
153
+ })).filter((c) => c.id && c.url);
154
+ if (clips.length) return { clips, error: null };
155
+ }
156
+ } catch { /* 落到模块清单 */ }
157
+ }
158
+
159
+ // ② 模块索引里的 boot 清单(站点不可达时的兜底)
160
+ try {
161
+ const index = await resolveModules(false);
162
+ const mod = (index.modules || []).find((m) => m.id === 'boot');
163
+ if (!mod || !mod.manifest || mod.status === 'disabled') return { clips: [], error: 'no-module' };
164
+ const res = await fetch(mod.manifest, {
165
+ headers: { accept: 'application/json' },
166
+ signal: AbortSignal.timeout(15000),
167
+ });
168
+ if (!res.ok) return { clips: [], error: 'http-' + res.status };
169
+ const doc = await res.json();
170
+ const list = Array.isArray(doc && doc.clips) ? doc.clips : [];
171
+ const base = mod.manifest.replace(/\/[^/]*$/, '');
172
+ const clips = list.map((c) => {
173
+ const file = String((c && (c.file || c.id)) || '');
174
+ const url = c && c.url ? String(c.url) : (base + '/' + file);
175
+ return {
176
+ id: String((c && c.id) || file.replace(/\.mp4$/i, '')),
177
+ title: String((c && (c.name || c.title)) || c.id || file),
178
+ url,
179
+ bytes: Number(c && c.bytes) || null,
180
+ sha256: c && c.sha256 ? String(c.sha256) : null,
181
+ durationSec: Number(c && c.durationSec) || null,
182
+ faststart: c && c.faststart === true,
183
+ };
184
+ }).filter((c) => c.id && c.url);
185
+ return { clips, error: null };
186
+ } catch (e) {
187
+ return { clips: [], error: 'network' };
188
+ }
189
+ }
190
+
191
+ export function registerBootRoutes(webServer, c) {
192
+ const { disposers, base: BASE, log } = c;
193
+
194
+ /* ① 候选 + 本地状态(面板一次拿全) */
195
+ disposers.push(webServer.register({
196
+ kind: 'exact',
197
+ path: `${BASE}/boot/list`,
198
+ handler: async (req, res) => {
199
+ let force = false;
200
+ try { force = new URL(req.url || '/', 'http://x').searchParams.get('refresh') === '1'; } catch { /* ignore */ }
201
+ if (force) await resolveModules(true);
202
+ const [boot, candidates] = await Promise.all([Promise.resolve(readBoot()), fetchCandidates()]);
203
+ const local = listLocalClips();
204
+ const localById = new Map(local.map((x) => [x.id, x]));
205
+ const clips = candidates.clips.map((c) => {
206
+ const have = localById.get(c.id) || null;
207
+ return { ...c, installed: Boolean(have), localBytes: have ? have.bytes : null };
208
+ });
209
+ // 本地有、清单里没有的(我们换了站点/清单):也列出来,否则用户删不掉。
210
+ const known = new Set(clips.map((c) => c.id));
211
+ for (const it of local) {
212
+ if (known.has(it.id)) continue;
213
+ clips.push({
214
+ id: it.id, title: it.title, url: null, bytes: null, sha256: null,
215
+ durationSec: it.durationSec, faststart: true,
216
+ installed: true, localBytes: it.bytes, orphan: true,
217
+ });
218
+ }
219
+ sendJson(res, 200, {
220
+ ok: true,
221
+ enabled: boot.enabled,
222
+ clipId: boot.clipId,
223
+ enterMode: boot.enterMode,
224
+ sound: boot.sound,
225
+ volume: boot.volume,
226
+ fadeMs: boot.fadeMs,
227
+ minShowMs: boot.minShowMs,
228
+ maxHoldMs: boot.maxHoldMs,
229
+ sound: boot.sound,
230
+ clips,
231
+ error: candidates.error,
232
+ });
233
+ },
234
+ }));
235
+
236
+ /* ② 下载一条到本地 */
237
+ disposers.push(webServer.register({
238
+ kind: 'exact',
239
+ path: `${BASE}/boot/download`,
240
+ handler: async (req, res) => {
241
+ const id = await readIdBody(req);
242
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
243
+ const { clips } = await fetchCandidates();
244
+ const item = clips.find((c) => c.id === id);
245
+ if (!item) { sendJson(res, 404, { ok: false, error: 'not-in-catalog' }); return; }
246
+ const dest = clipFile(id);
247
+ if (!dest) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
248
+ try {
249
+ // 分块并发下载:跨境单连接常常只有几百 KB/s,4 路并发能明显缩短等待。
250
+ const buf = await fetchClipBytes(item.url, 4);
251
+ const ct = 'video/mp4'; // 并行分段之后没有单一响应头可读,类型下面用 ftyp 验
252
+ // 站点把未知路径回落到 HTML —— 这类"伪 200"绝不能当成视频存下来(否则每次开机黑屏)。
253
+ // 只认文件头:`ftyp` 在偏移 4。比 content-type 更可靠(站点回退页是 HTML,
254
+ // 而并行分段之后我们也拿不到单一响应头)。
255
+ if (buf.length < 1024 || buf.toString('latin1', 4, 8) !== 'ftyp') {
256
+ sendJson(res, 422, { ok: false, error: 'not-a-video', detail: ct || 'no content-type' });
257
+ return;
258
+ }
259
+ if (buf.length > CLIP_MAX_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
260
+ writeFileSync(dest, buf);
261
+ try {
262
+ writeFileSync(join(bootDir(), id + '.json'), JSON.stringify({
263
+ title: item.title, durationSec: item.durationSec, bytes: buf.length, sha256: item.sha256 || null,
264
+ }, null, 2) + '\n');
265
+ } catch { /* meta 只是锦上添花 */ }
266
+ if (typeof log === 'function') log(`[boot] downloaded ${id} (${(buf.length / 1048576).toFixed(1)} MB)`);
267
+ sendJson(res, 200, { ok: true, id, bytes: buf.length });
268
+ } catch (e) {
269
+ sendJson(res, 500, { ok: false, error: 'fetch-failed', detail: String((e && e.message) || e) });
270
+ }
271
+ },
272
+ }));
273
+
274
+ /* ③ 选择(并顺手启用) */
275
+ disposers.push(webServer.register({
276
+ kind: 'exact',
277
+ path: `${BASE}/boot/select`,
278
+ handler: async (req, res) => {
279
+ const id = await readIdBody(req);
280
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
281
+ if (!clipExists(id)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
282
+ const boot = writeBoot({ clipId: id, enabled: true });
283
+ if (typeof log === 'function') log(`[boot] selected ${id}`);
284
+ sendJson(res, 200, { ok: true, ...boot });
285
+ },
286
+ }));
287
+
288
+ /* ④ 开关 */
289
+ disposers.push(webServer.register({
290
+ kind: 'exact',
291
+ path: `${BASE}/boot/toggle`,
292
+ handler: async (req, res) => {
293
+ let enabled = false;
294
+ try {
295
+ const text = await readBodyText(req, 8 * 1024);
296
+ const doc = JSON.parse(text || '{}');
297
+ enabled = Boolean(doc && doc.enabled);
298
+ } catch { /* 坏请求当 false */ }
299
+ const boot = writeBoot({ enabled });
300
+ sendJson(res, 200, { ok: true, ...boot });
301
+ },
302
+ }));
303
+
304
+ /* ⑤ 设置(淡出 / 最短显示 / 最长停留) */
305
+ disposers.push(webServer.register({
306
+ kind: 'exact',
307
+ path: `${BASE}/boot/settings`,
308
+ handler: async (req, res) => {
309
+ let patch = {};
310
+ try {
311
+ const text = await readBodyText(req, 8 * 1024);
312
+ const doc = JSON.parse(text || '{}');
313
+ if (doc && typeof doc === 'object') patch = doc;
314
+ } catch { /* 坏请求 = 不改 */ }
315
+ const boot = writeBoot({
316
+ ...(patch.enterMode !== undefined ? { enterMode: String(patch.enterMode) } : {}),
317
+ ...(patch.sound !== undefined ? { sound: Boolean(patch.sound) } : {}),
318
+ ...(patch.volume !== undefined ? { volume: Number(patch.volume) } : {}),
319
+ ...(patch.fadeMs !== undefined ? { fadeMs: Number(patch.fadeMs) } : {}),
320
+ ...(patch.minShowMs !== undefined ? { minShowMs: Number(patch.minShowMs) } : {}),
321
+ ...(patch.maxHoldMs !== undefined ? { maxHoldMs: Number(patch.maxHoldMs) } : {}),
322
+ });
323
+ sendJson(res, 200, { ok: true, ...boot });
324
+ },
325
+ }));
326
+
327
+ /* ⑥ 删除本地片段 */
328
+ disposers.push(webServer.register({
329
+ kind: 'exact',
330
+ path: `${BASE}/boot/remove`,
331
+ handler: async (req, res) => {
332
+ const id = await readIdBody(req);
333
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
334
+ const boot = readBoot();
335
+ if (boot.clipId === id) writeBoot({ clipId: '', enabled: false });
336
+ const ok = removeClip(id);
337
+ sendJson(res, 200, { ok, id });
338
+ },
339
+ }));
340
+
341
+ /* ⑦ 流式播放(Range) */
342
+ disposers.push(webServer.register({
343
+ kind: 'exact',
344
+ path: `${BASE}/boot/clip`,
345
+ handler: async (req, res) => {
346
+ const boot = readBoot();
347
+ const id = boot.clipId;
348
+ const file = clipFile(id);
349
+ if (!file || !existsSync(file)) { res.statusCode = 404; res.end('no clip'); return; }
350
+ const size = statSync(file).size;
351
+ const range = String((req.headers && req.headers.range) || '');
352
+ const type = 'video/mp4';
353
+ // Range:`bytes=start-end` —— Chromium 播放本地大文件时会先发一小段探测请求。
354
+ const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
355
+ if (m) {
356
+ let start = m[1] === '' ? null : Number(m[1]);
357
+ let end = m[2] === '' ? null : Number(m[2]);
358
+ if (start === null && end !== null) { start = Math.max(0, size - end); end = size - 1; }
359
+ if (start === null) start = 0;
360
+ if (end === null || end >= size) end = size - 1;
361
+ if (start > end || start >= size) {
362
+ res.statusCode = 416;
363
+ if (typeof res.setHeader === 'function') res.setHeader('content-range', `bytes */${size}`);
364
+ res.end();
365
+ return;
366
+ }
367
+ if (typeof res.setHeader === 'function') {
368
+ res.setHeader('content-type', type);
369
+ res.setHeader('content-length', String(end - start + 1));
370
+ res.setHeader('content-range', `bytes ${start}-${end}/${size}`);
371
+ res.setHeader('accept-ranges', 'bytes');
372
+ res.setHeader('cache-control', 'no-store');
373
+ }
374
+ res.statusCode = 206;
375
+ createReadStream(file, { start, end }).pipe(res);
376
+ return;
377
+ }
378
+ if (typeof res.setHeader === 'function') {
379
+ res.setHeader('content-type', type);
380
+ res.setHeader('content-length', String(size));
381
+ res.setHeader('accept-ranges', 'bytes');
382
+ res.setHeader('cache-control', 'no-store');
383
+ }
384
+ res.statusCode = 200;
385
+ createReadStream(file).pipe(res);
386
+ },
387
+ }));
388
+ }