dsh-folia 2.5.1 → 2.5.8

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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * boot/index.js — 开机动画的宿主半边:本地片段仓库 + 注入 + 屏幕脚本。
3
+ *
4
+ * 设计三条铁律(每条都对应一次真实教训):
5
+ *
6
+ * 1. **关闭 = 什么都不注入**。不是"注入了再让脚本自己退出"—— 那等于整个东西还在跑。
7
+ * 2. **启动路径上零网络**。片段必须已在本地(`~/.dsh-folia/boot/`),注入的内容是
8
+ * 内联 JSON,启动时连一次请求都不发。
9
+ * 3. **任何异常都必须放行**。注入的脚本顶层 try/catch,超时自毁,绝不把用户挡在门外。
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { extname, join } from 'node:path';
14
+
15
+ /** 本地片段目录(与 config.json 同一个数据根)。 */
16
+ export function bootHome() {
17
+ const base = process.env.DSH_FOLIA_HOME || join(homedir(), '.dsh-folia');
18
+ return base;
19
+ }
20
+ export function bootDir() {
21
+ const d = join(bootHome(), 'boot');
22
+ try { if (!existsSync(d)) mkdirSync(d, { recursive: true }); } catch { /* 只读盘:后续写入会失败并如实报错 */ }
23
+ return d;
24
+ }
25
+ export function bootConfigPath() {
26
+ return join(bootHome(), 'config.json');
27
+ }
28
+
29
+ /** 读整个 config.json(失败返回空对象,绝不抛)。 */
30
+ function readConfig() {
31
+ try {
32
+ return JSON.parse(readFileSync(bootConfigPath(), 'utf8')) || {};
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 读写 boot 段。**只碰 `boot` 这一个键** —— 壁纸那边的设置一个字都不能动。
40
+ * 默认值就是"没有开机动画":enabled=false、clipId 空。这正是首次安装时的期望状态。
41
+ */
42
+ export function readBoot() {
43
+ const cfg = readConfig();
44
+ const raw = cfg && typeof cfg.boot === 'object' && cfg.boot ? cfg.boot : {};
45
+ return {
46
+ enabled: raw.enabled === true,
47
+ clipId: typeof raw.clipId === 'string' ? raw.clipId : '',
48
+ // 进入方式:tail = 播完再进(默认);ready = 应用就绪就进;click = 点一下才进。
49
+ // 之前没有这个字段,一律「就绪即进」,于是 8~15 秒的片子 1.2 秒就被掐掉。
50
+ enterMode: ['tail', 'ready', 'click'].includes(String(raw.enterMode)) ? String(raw.enterMode) : 'tail',
51
+ fadeMs: Number.isFinite(Number(raw.fadeMs)) ? Math.min(3000, Math.max(200, Number(raw.fadeMs))) : 900,
52
+ minShowMs: Number.isFinite(Number(raw.minShowMs)) ? Math.min(5000, Math.max(0, Number(raw.minShowMs))) : 1200,
53
+ // 最长停留:安全上限,不是「正常退出点」。默认 20 秒,足够一集 15 秒的片子播完。
54
+ maxHoldMs: Number.isFinite(Number(raw.maxHoldMs)) ? Math.min(60000, Math.max(3000, Number(raw.maxHoldMs))) : 20000,
55
+ sound: raw.sound === true,
56
+ };
57
+ }
58
+
59
+ export function writeBoot(patch) {
60
+ const cfg = readConfig();
61
+ const cur = cfg && typeof cfg.boot === 'object' && cfg.boot ? cfg.boot : {};
62
+ cfg.boot = { ...cur, ...patch };
63
+ writeFileSync(bootConfigPath(), JSON.stringify(cfg, null, 2) + '\n');
64
+ return readBoot();
65
+ }
66
+
67
+ /** 允许的 id:目录名安全字符,防止拿它拼路径时被穿越。 */
68
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
69
+ export function clipFile(id) {
70
+ if (!ID_RE.test(String(id || ''))) return null;
71
+ const p = join(bootDir(), String(id) + '.mp4');
72
+ return p;
73
+ }
74
+ export function clipExists(id) {
75
+ const p = clipFile(id);
76
+ return Boolean(p && existsSync(p) && statSync(p).size > 0);
77
+ }
78
+
79
+ /** 本地已装片段(只有 mp4;meta 里带标题等可选信息)。 */
80
+ export function listLocalClips() {
81
+ const dir = bootDir();
82
+ const out = [];
83
+ let names = [];
84
+ try { names = readdirSync(dir); } catch { return out; }
85
+ for (const n of names) {
86
+ if (extname(n).toLowerCase() !== '.mp4') continue;
87
+ const id = n.slice(0, -4);
88
+ const abs = join(dir, n);
89
+ let bytes = 0;
90
+ try { bytes = statSync(abs).size; } catch { continue; }
91
+ let meta = {};
92
+ try { meta = JSON.parse(readFileSync(join(dir, id + '.json'), 'utf8')) || {}; } catch { /* 没 meta 也能用 */ }
93
+ out.push({ id, bytes, title: meta.title || id, durationSec: meta.durationSec || null });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export function removeClip(id) {
99
+ const p = clipFile(id);
100
+ if (!p) return false;
101
+ try { if (existsSync(p)) rmSync(p); } catch { return false; }
102
+ try { const m = join(bootDir(), String(id) + '.json'); if (existsSync(m)) rmSync(m); } catch { /* ignore */ }
103
+ return true;
104
+ }
105
+
106
+ /* ── 注入的启动屏幕脚本 ─────────────────────────────────────────────────────
107
+ * 纯 ES5 风格、零依赖、自包含。为什么不用模块:它在 `<head>` 里同步执行,
108
+ * 早于应用外壳,任何 import/await 都会让它晚一拍(那就会先闪一下界面)。
109
+ */
110
+ export const BOOT_SCREEN_SOURCE = String.raw`
111
+ (function () {
112
+ var cfg = globalThis.__FOLIA_BOOT__;
113
+ if (!cfg || !cfg.clip) return;
114
+ var doc = document;
115
+ var root = null;
116
+ var video = null;
117
+ var done = false;
118
+
119
+ function teardown() {
120
+ if (done) return;
121
+ done = true;
122
+ try { if (root) root.style.opacity = "0"; } catch (e) {}
123
+ setTimeout(function () {
124
+ try { if (video) { video.pause(); video.removeAttribute("src"); video.load && video.load(); } } catch (e) {}
125
+ try { if (root && root.parentNode) root.parentNode.removeChild(root); } catch (e) {}
126
+ }, (cfg.fadeMs || 900) + 60);
127
+ }
128
+
129
+ try {
130
+ root = doc.createElement("div");
131
+ root.id = "__folia_boot";
132
+ root.setAttribute("style",
133
+ "position:fixed;inset:0;z-index:2147483647;background:#000;" +
134
+ "display:flex;align-items:center;justify-content:center;" +
135
+ "transition:opacity " + (cfg.fadeMs || 900) + "ms ease;opacity:1;");
136
+ video = doc.createElement("video");
137
+ video.setAttribute("muted", "");
138
+ video.muted = true;
139
+ video.playsInline = true;
140
+ video.preload = "auto";
141
+ video.setAttribute("style", "width:100%;height:100%;object-fit:cover;");
142
+ video.src = cfg.clip;
143
+ // 解码失败 / 文件不在:立刻放行,绝不留黑屏。
144
+ video.addEventListener("error", teardown, { once: true });
145
+ root.appendChild(video);
146
+ (doc.body || doc.documentElement).appendChild(root);
147
+ var p = video.play();
148
+ if (p && typeof p.catch === "function") p.catch(function () { teardown(); });
149
+ } catch (e) {
150
+ teardown();
151
+ return;
152
+ }
153
+
154
+ // 退场时机由 enterMode 决定:
155
+ // tail 播完再进(默认)—— 视频 ended 即退场,真正把片子看完
156
+ // ready 应用就绪就进 —— 原来那套(1.2 秒左右就掐掉,短、但看不到片尾)
157
+ // click 点一下才进 —— 仪式感
158
+ // maxHoldMs 是安全上限(默认 20 秒),任何模式下到点必退,防止卡死。
159
+ var mode = cfg.enterMode || "tail";
160
+ var t0 = Date.now();
161
+ var ended = false;
162
+ try { video.addEventListener("ended", function () { ended = true; }); } catch (e) {}
163
+
164
+ var timer = setInterval(function () {
165
+ var elapsed = Date.now() - t0;
166
+ if (elapsed >= (cfg.maxHoldMs || 20000)) { clearInterval(timer); teardown(); return; }
167
+ if (mode === "click") return;
168
+ if (mode === "tail") {
169
+ // 播完即退;再等 minShowMs 一拍,避免极短片「闪一下」。
170
+ if (ended && elapsed >= (cfg.minShowMs || 0)) { clearInterval(timer); teardown(); }
171
+ return;
172
+ }
173
+ // ready:应用就绪 且 已显示够 minShowMs
174
+ var host = doc.querySelector("#root");
175
+ var ready = !!(host && host.children && host.children.length > 0);
176
+ if (ready && elapsed >= (cfg.minShowMs || 1200)) { clearInterval(timer); teardown(); }
177
+ }, 120);
178
+
179
+ // 硬兜底:即使上面的判定因为任何原因没跑(异常、被替换),也在 maxHold+2s 后消失。
180
+ setTimeout(function () { try { clearInterval(timer); } catch (e) {} teardown(); }, (cfg.maxHoldMs || 20000) + 2000);
181
+
182
+ // 用户放行:点击 / Esc / 空格。
183
+ try {
184
+ root.addEventListener("click", teardown, { once: true });
185
+ doc.addEventListener("keydown", function (e) {
186
+ if (e.key === "Escape" || e.key === " ") teardown();
187
+ }, { once: true });
188
+ } catch (e) {}
189
+ })();
190
+ `;
191
+
192
+ /**
193
+ * 装注入。**订阅在 apply() 里做一次**,每次 index 渲染时按当前配置决定注不注。
194
+ *
195
+ * @param {any} ctx cordis 上下文
196
+ * @param {(msg: string) => void} note 日志
197
+ */
198
+ export function installBootInjection(ctx, note) {
199
+ if (!ctx || typeof ctx.on !== 'function') {
200
+ note('[boot] 这个宿主没有 webserver/index-inject 事件,开机动画不可用');
201
+ return;
202
+ }
203
+ ctx.on('webserver/index-inject', (table) => {
204
+ if (!Array.isArray(table)) return;
205
+ const boot = readBoot();
206
+ // 关 = 一行都不注入(不是"注入了再自己退出")。
207
+ if (!boot.enabled || !boot.clipId) return;
208
+ if (!clipExists(boot.clipId)) return; // 片段被删了:静默不注入,别让用户卡黑屏
209
+ const cfg = {
210
+ clip: '/folia/boot/clip?v=' + encodeURIComponent(boot.clipId),
211
+ fadeMs: boot.fadeMs,
212
+ minShowMs: boot.minShowMs,
213
+ maxHoldMs: boot.maxHoldMs,
214
+ enterMode: boot.enterMode,
215
+ sound: boot.sound,
216
+ };
217
+ table.push({ kind: 'script', placement: 'head', text: 'globalThis.__FOLIA_BOOT__=' + JSON.stringify(cfg) });
218
+ table.push({ kind: 'script', placement: 'head', text: BOOT_SCREEN_SOURCE });
219
+ });
220
+ }