dsh-folia 4.0.8 → 4.3.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/client.js CHANGED
@@ -17180,6 +17180,596 @@ window.__ModuleLoader__.load({
17180
17180
  return React.createElement(BootTabPanel, null);
17181
17181
  }
17182
17182
 
17183
+ // ── 内联模块:src/panel-tabs-pets.js —— 「桌宠」页签:站点清单/安装/使用/卸载/开关 — 渲染由 pet-layer.js 负责,这里只管动作(构建期注入;勿手改本段)──
17184
+ /**
17185
+ * panel-tabs-pets.js — 「桌宠」页签:站点清单 / 安装 / 使用 / 卸载 / 开关 / 缩放。
17186
+ *
17187
+ * 与「开机动画」页签同构(fetch → busy → note → 卡片列表),差别只有一处:
17188
+ * 开机动画的播放不在前端(启动那一刻应用还没挂载),而桌宠**就活在前端** ——
17189
+ * 这里的每个动作最终都落到宿主 `/folia/pets/*`,渲染由 pet-layer.js 负责。
17190
+ */
17191
+
17192
+ const PET_STYLE = {
17193
+ row: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
17194
+ card: {
17195
+ display: "flex", flexDirection: "column", gap: "8px", padding: "12px",
17196
+ borderRadius: "10px", border: "1px solid rgba(255,255,255,0.14)",
17197
+ background: "rgba(255,255,255,0.04)", minWidth: "220px", flex: "1 1 220px",
17198
+ },
17199
+ title: { fontSize: "13px", fontWeight: 600 },
17200
+ meta: { fontSize: "11px", opacity: 0.72 },
17201
+ grid: { display: "flex", gap: "10px", flexWrap: "wrap", marginTop: "10px" },
17202
+ };
17203
+
17204
+ function petFmtBytes(n) {
17205
+ const v = Number(n) || 0;
17206
+ if (!v) return "";
17207
+ if (v < 1024 * 1024) return Math.round(v / 1024) + " KB";
17208
+ return (v / 1048576).toFixed(1) + " MB";
17209
+ }
17210
+
17211
+ function PetsTabPanel() {
17212
+ const [state, setState] = React.useState(null);
17213
+ const [busy, setBusy] = React.useState("");
17214
+ const [note, setNote] = React.useState(null);
17215
+
17216
+ const load = React.useCallback(async (force) => {
17217
+ try {
17218
+ const resp = await apiJson("/pets/list" + (force ? "?refresh=1" : ""), { parse: "always" });
17219
+ const doc = (resp && resp.data) || null;
17220
+ if (resp && resp.ok && doc && doc.ok) setState(doc);
17221
+ else setNote({ ok: false, text: weT("读取失败:") + ((resp && resp.error) || "unknown") });
17222
+ } catch (e) {
17223
+ setNote({ ok: false, text: weT("请求失败:") + ((e && e.message) || weT("网络错误")) });
17224
+ }
17225
+ }, []);
17226
+
17227
+ React.useEffect(() => { load(false); }, [load]);
17228
+
17229
+ const act = async (kind, payload, label) => {
17230
+ setBusy(kind + ":" + (payload.id || ""));
17231
+ setNote(null);
17232
+ try {
17233
+ const resp = await apiPostJson("/pets/" + kind, payload || {}, { parse: "always" });
17234
+ const r = (resp && resp.data) || null;
17235
+ if (resp && resp.ok && r && r.ok) {
17236
+ if (kind === "install") setNote({ ok: true, text: weT("已安装并启用:") + (label || "") });
17237
+ if (kind === "remove") setNote({ ok: true, text: weT("已卸载:") + (label || "") });
17238
+ if (kind === "config") setNote({ ok: true, text: weT("已保存") });
17239
+ await load(true);
17240
+ } else {
17241
+ const code = (r && r.error) || "failed";
17242
+ setNote({ ok: false, text: weT("操作失败:") + code });
17243
+ }
17244
+ } catch (e) {
17245
+ setNote({ ok: false, text: weT("请求失败:") + ((e && e.message) || weT("网络错误")) });
17246
+ } finally {
17247
+ setBusy("");
17248
+ }
17249
+ };
17250
+
17251
+ const pets = (state && state.pets) || [];
17252
+ const site = (state && state.site) || [];
17253
+ const cfg = (state && state.config) || { enabled: false, petId: "", scale: 1 };
17254
+ const installedIds = new Set(pets.map((p) => p.dirId));
17255
+
17256
+ if (!state) {
17257
+ return React.createElement("div", { className: "we-picker__section" },
17258
+ React.createElement("div", { className: "we-picker__hint" }, weT("正在读取桌宠…")));
17259
+ }
17260
+
17261
+ const children = [];
17262
+
17263
+ children.push(React.createElement("div", { className: "we-picker__section-head", key: "head" },
17264
+ React.createElement("span", { className: "we-picker__section-label" },
17265
+ weT("🐾 桌宠") + (pets.length ? " · " + pets.length : "")),
17266
+ React.createElement("button", {
17267
+ type: "button", className: "we-picker__btn", style: { marginLeft: "auto" },
17268
+ disabled: Boolean(busy), onClick: () => load(true),
17269
+ }, weT("刷新")),
17270
+ ));
17271
+
17272
+ children.push(React.createElement("div", { key: "hint", className: "we-picker__hint", style: { margin: "6px 0 10px" } },
17273
+ weT("桌宠由本插件自己绘制(雪碧图逐帧),不依赖其它宠物插件。它会跟着模型状态换动作:干活时换成工作行、干完变完成、出错变失败;安装后出现在右下角,可拖动,点一下会挥手并出声。")));
17274
+
17275
+ children.push(React.createElement("div", { key: "opts", style: { ...PET_STYLE.row, margin: "2px 0 8px" } },
17276
+ React.createElement("label", { style: { display: "flex", gap: "6px", alignItems: "center", fontSize: "12px" } },
17277
+ React.createElement("input", {
17278
+ type: "checkbox",
17279
+ checked: Boolean(cfg.enabled),
17280
+ disabled: Boolean(busy) || !pets.length,
17281
+ onChange: (e) => act("config", { enabled: e.target.checked }, ""),
17282
+ }),
17283
+ weT("显示桌宠"),
17284
+ ),
17285
+ React.createElement("label", { style: { display: "flex", gap: "6px", alignItems: "center", fontSize: "12px" } },
17286
+ React.createElement("input", {
17287
+ type: "checkbox",
17288
+ checked: Boolean(cfg.sound),
17289
+ disabled: Boolean(busy),
17290
+ onChange: (e) => act("config", { sound: e.target.checked }, ""),
17291
+ }),
17292
+ weT("声音"),
17293
+ ),
17294
+ React.createElement("label", { style: { display: "flex", gap: "4px", alignItems: "center", fontSize: "12px" } },
17295
+ weT("音量"),
17296
+ React.createElement("select", {
17297
+ className: "we-picker__playlist-select",
17298
+ value: String(cfg.volume == null ? 0.6 : cfg.volume),
17299
+ disabled: Boolean(busy) || !cfg.sound,
17300
+ onChange: (e) => act("config", { volume: Number(e.target.value) }, ""),
17301
+ },
17302
+ [[0.2, "20%"], [0.4, "40%"], [0.6, "60%"], [0.8, "80%"], [1, "100%"]].map(([v, lb]) =>
17303
+ React.createElement("option", { key: String(v), value: String(v) }, lb)),
17304
+ ),
17305
+ ),
17306
+ React.createElement("button", {
17307
+ type: "button", className: "we-picker__btn", disabled: Boolean(busy),
17308
+ title: weT("把桌宠挪回右下角"),
17309
+ onClick: () => { try { localStorage.removeItem("wePetPos"); } catch { /* ignore */ } setNote({ ok: true, text: weT("位置已复位(下一次出现回到右下角)") }); },
17310
+ }, weT("复位位置")),
17311
+ React.createElement("label", { style: { display: "flex", gap: "4px", alignItems: "center", fontSize: "12px" } },
17312
+ weT("大小"),
17313
+ React.createElement("select", {
17314
+ className: "we-picker__playlist-select",
17315
+ value: String(cfg.scale || 1),
17316
+ disabled: Boolean(busy),
17317
+ onChange: (e) => act("config", { scale: Number(e.target.value) }, ""),
17318
+ },
17319
+ [[0.6, "60%"], [0.8, "80%"], [1, "100%"], [1.3, "130%"], [1.6, "160%"]].map(([v, t]) =>
17320
+ React.createElement("option", { key: String(v), value: String(v) }, t)),
17321
+ ),
17322
+ ),
17323
+ ));
17324
+
17325
+ if (note) {
17326
+ children.push(React.createElement("div", {
17327
+ key: "note",
17328
+ className: "we-picker__hint",
17329
+ style: { color: note.ok ? "rgba(120,220,150,0.95)" : "rgba(255,140,140,0.95)", margin: "2px 0 8px" },
17330
+ }, note.text));
17331
+ }
17332
+
17333
+ const btn = (text, onClick, disabled) => React.createElement("button", {
17334
+ type: "button", className: "we-picker__btn", disabled: Boolean(disabled),
17335
+ style: { flex: "1 1 auto" }, onClick,
17336
+ }, text);
17337
+
17338
+ // 已装:换一只 / 卸载
17339
+ children.push(React.createElement("div", { key: "installed", style: PET_STYLE.grid },
17340
+ pets.length
17341
+ ? pets.map((p) => {
17342
+ const isActive = cfg.petId === p.dirId || cfg.petId === p.id;
17343
+ const key = "inst:" + p.dirId;
17344
+ const actions = [];
17345
+ actions.push(btn(isActive ? weT("使用中") : weT("使用"), () => act("config", { enabled: true, petId: p.dirId }, p.name), isActive || Boolean(busy)));
17346
+ actions.push(btn(weT("卸载"), () => act("remove", { id: p.dirId }, p.name), Boolean(busy)));
17347
+ return React.createElement("div", { key, style: PET_STYLE.card },
17348
+ React.createElement("img", {
17349
+ src: p.sheet, alt: "", loading: "lazy", decoding: "async",
17350
+ style: { width: "100%", maxHeight: "96px", objectFit: "contain", background: "rgba(0,0,0,0.25)", borderRadius: "6px" },
17351
+ onError: (e) => { e.target.style.display = "none"; },
17352
+ }),
17353
+ React.createElement("div", { style: PET_STYLE.title }, p.name),
17354
+ React.createElement("div", { style: PET_STYLE.meta },
17355
+ (p.columns || 8) + weT(" 列 · ") + (p.cell ? p.cell.width + "×" + p.cell.height : "")),
17356
+ React.createElement("div", { style: PET_STYLE.row }, actions),
17357
+ );
17358
+ })
17359
+ : React.createElement("div", { className: "we-picker__hint" }, weT("还没有安装任何桌宠 —— 从下面的站点清单里挑一只。")),
17360
+ ));
17361
+
17362
+ // 站点:未装的可安装
17363
+ const available = site.filter((s) => !installedIds.has(s.id));
17364
+ children.push(React.createElement("div", { key: "siteHead", className: "we-picker__section-head", style: { marginTop: "14px" } },
17365
+ React.createElement("span", { className: "we-picker__section-label" },
17366
+ weT("站点桌宠") + (site.length ? " · " + site.length : "")),
17367
+ state.siteError
17368
+ ? React.createElement("span", { className: "we-picker__hint", style: { marginLeft: "auto" } }, weT("站点不可达"))
17369
+ : null,
17370
+ ));
17371
+
17372
+ if (available.length) {
17373
+ children.push(React.createElement("div", { key: "site", style: PET_STYLE.grid },
17374
+ available.map((s) => {
17375
+ const isBusy = busy === "install:" + s.id;
17376
+ return React.createElement("div", { key: "site:" + s.id, style: PET_STYLE.card },
17377
+ s.cover
17378
+ ? React.createElement("img", {
17379
+ src: s.cover, alt: "", loading: "lazy", decoding: "async",
17380
+ style: { width: "100%", maxHeight: "96px", objectFit: "cover", borderRadius: "6px" },
17381
+ onError: (e) => { e.target.style.display = "none"; },
17382
+ })
17383
+ : null,
17384
+ React.createElement("div", { style: PET_STYLE.title }, s.title || s.id),
17385
+ React.createElement("div", { style: PET_STYLE.meta }, petFmtBytes(s.bytes)),
17386
+ React.createElement("div", { style: PET_STYLE.row },
17387
+ btn(isBusy ? weT("安装中…") : weT("安装"), () => act("install", { id: s.id }, s.title || s.id), Boolean(busy))),
17388
+ );
17389
+ }),
17390
+ ));
17391
+ } else if (!site.length) {
17392
+ children.push(React.createElement("div", { key: "siteEmpty", className: "we-picker__hint", style: { marginTop: "6px" } },
17393
+ weT("站点上还没有桌宠。")));
17394
+ }
17395
+
17396
+ return React.createElement("div", { className: "we-picker__section" }, children);
17397
+ }
17398
+
17399
+ /** 页签组装点调用它。 */
17400
+ function renderPetsTab() {
17401
+ return React.createElement(PetsTabPanel, null);
17402
+ }
17403
+
17404
+ // ── 内联模块:src/pet-layer.js —— 桌宠渲染层:canvas 逐帧绘 sprite2d 雪碧图 + 拖动 + 状态机(自渲染,不依赖第三方宠物插件)(构建期注入;勿手改本段)──
17405
+ /**
17406
+ * pet-layer.js — 桌宠**渲染层**(自己画,不依赖任何第三方宠物插件)。
17407
+ *
17408
+ * 形态:`<body>` 下的一个 fixed 容器 + 一张 canvas,用 sprite2d 雪碧图逐帧绘制。
17409
+ *
17410
+ * 契约(与宿主 `/folia/pets/list` 一致):
17411
+ * pets[i] = { id, dirId, name, renderer, cell:{width,height}, columns, frames:number[9],
17412
+ * tracks:{ <轨道名>: { durations:number[] } }, sheet:'/folia/pets/sheet?v=<dirId>' }
17413
+ *
17414
+ * 行序(9 行,dsh-pet 的 contract,缺轨道时回落 idle):
17415
+ * 0 idle · 1 右跑 · 2 左跑 · 3 挥手 · 4 跳 · 5 失败 · 6 等待 · 7 工作 · 8 完成
17416
+ *
17417
+ * 为什么整层是"命令式 DOM + rAF"而不是 React:它每秒重绘几十次,进 React 只会让
17418
+ * 每次 setState 触发整棵树协调;这里只碰一个 canvas 的像素,与面板渲染互不干扰。
17419
+ */
17420
+
17421
+ const PET_LAYER_ID = "we-pet-layer";
17422
+ const PET_ROW = { idle: 0, runRight: 1, runLeft: 2, wave: 3, jump: 4, fail: 5, wait: 6, work: 7, done: 8 };
17423
+ const PET_DEFAULT_FRAME_MS = 120;
17424
+ /**
17425
+ * 活动检测:DSH 的客户端 `ctx` 只暴露 effect/get/slots/surface,没有会话事件,
17426
+ * 所以"模型在不在干活"只能从**页面本身**读。两条独立信号,任一命中即认为在工作:
17427
+ * ① DOM 特征:选择器(默认覆盖常见的 streaming / 生成中 标记)
17428
+ * ② 文本特征:屏幕上出现"正在生成 / 思考中 / Stop generating"这类字眼
17429
+ * 两条都读不到时**安静地停在 idle** —— 宁可不动,也不要让宠物乱跳。
17430
+ */
17431
+ const PET_ACTIVITY_SELECTORS = [
17432
+ '[data-dsh-streaming]', '[data-streaming="true"]', '.ds-streaming',
17433
+ '[class*="streaming"]', '[class*="isGenerating"]', '[aria-busy="true"]',
17434
+ ];
17435
+ const PET_ACTIVITY_TEXTS = ['正在生成', '思考中', '停止生成', 'Stop generating', 'Thinking'];
17436
+ /** 检测节流(毫秒):一次 body 扫描比一次布局还便宜,但没必要每帧跑。 */
17437
+ const PET_ACTIVITY_POLL_MS = 500;
17438
+ /** 光标静止后多久切回 idle(毫秒)。 */
17439
+ const PET_WAVE_IDLE_MS = 9000;
17440
+
17441
+ /**
17442
+ * 声音:包内 `sounds/<事件>.ogg|mp3|wav` 优先(manifest.sounds 显式声明),
17443
+ * 没有就用 Web Audio 合成 —— 一只宠物没声音比"声音是版权素材"糟得多。
17444
+ * 全部失败都静默:声音是可选项,不该有能力打断渲染。
17445
+ */
17446
+ function petVoice(ctxWindow, sounds, cfg) {
17447
+ const enabled = Boolean(cfg && cfg.sound);
17448
+ const volume = Math.max(0, Math.min(1, Number(cfg && cfg.volume) || 0));
17449
+ let audioCtx = null;
17450
+ const cache = new Map();
17451
+
17452
+ function ensureCtx() {
17453
+ if (audioCtx || typeof ctxWindow.AudioContext !== 'function') return audioCtx;
17454
+ try { audioCtx = new ctxWindow.AudioContext(); } catch { audioCtx = null; }
17455
+ return audioCtx;
17456
+ }
17457
+
17458
+ /** 合成提示音:不同事件不同音高与波形,短、克制。 */
17459
+ function synth(kind) {
17460
+ const ac = ensureCtx();
17461
+ if (!ac) return;
17462
+ const spec = kind === "done" ? { f: 880, d: 0.09, w: "sine" }
17463
+ : kind === "fail" ? { f: 220, d: 0.16, w: "sawtooth" }
17464
+ : { f: 620, d: 0.08, w: "triangle" };
17465
+ try {
17466
+ const osc = ac.createOscillator();
17467
+ const gain = ac.createGain();
17468
+ osc.type = spec.w;
17469
+ osc.frequency.value = spec.f;
17470
+ gain.gain.value = 0.0001;
17471
+ osc.connect(gain);
17472
+ gain.connect(ac.destination);
17473
+ const now = ac.currentTime;
17474
+ gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, volume * 0.25), now + 0.01);
17475
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + spec.d);
17476
+ osc.start(now);
17477
+ osc.stop(now + spec.d + 0.02);
17478
+ } catch { /* 合成失败就算了 */ }
17479
+ }
17480
+
17481
+ function file(kind) {
17482
+ const url = sounds && sounds[kind];
17483
+ if (!url) return null;
17484
+ let el = cache.get(kind);
17485
+ if (!el) {
17486
+ try { el = new ctxWindow.Audio(apiUrl(url)); } catch { return null; }
17487
+ el.preload = "auto";
17488
+ cache.set(kind, el);
17489
+ }
17490
+ return el;
17491
+ }
17492
+
17493
+ return {
17494
+ get enabled() { return enabled; },
17495
+ play(kind) {
17496
+ if (!enabled || volume <= 0) return;
17497
+ const el = file(kind);
17498
+ if (el) {
17499
+ try { el.currentTime = 0; el.volume = volume; const p = el.play(); if (p && p.catch) p.catch(() => synth(kind)); return; } catch { /* 落回合成 */ }
17500
+ }
17501
+ synth(kind);
17502
+ },
17503
+ destroy() {
17504
+ cache.clear();
17505
+ try { if (audioCtx && typeof audioCtx.close === "function") audioCtx.close(); } catch { /* ignore */ }
17506
+ audioCtx = null;
17507
+ },
17508
+ };
17509
+ }
17510
+
17511
+ /** 页面里现在像不像"模型在干活"。任何异常都当 false。 */
17512
+ function petActivityProbe() {
17513
+ try {
17514
+ if (typeof document === 'undefined' || !document.body) return false;
17515
+ for (const sel of PET_ACTIVITY_SELECTORS) {
17516
+ if (document.querySelector(sel)) return true;
17517
+ }
17518
+ const text = document.body.innerText || "";
17519
+ for (const marker of PET_ACTIVITY_TEXTS) {
17520
+ if (text.indexOf(marker) !== -1) return true;
17521
+ }
17522
+ } catch { /* ignore */ }
17523
+ return false;
17524
+ }
17525
+
17526
+ /**
17527
+ * 桌宠实例:一只宠物一条生命周期(换宠物 = 销毁重来)。
17528
+ * 不导出(构建期内联进同一作用域),只由 mountPetLayer 使用。
17529
+ */
17530
+ function createPetLayer(layerEl, canvas, pet, scale, cfg) {
17531
+ const ctx = canvas.getContext("2d");
17532
+ const img = new Image();
17533
+ let frame = 0;
17534
+ let row = PET_ROW.idle;
17535
+ let acc = 0;
17536
+ let last = 0;
17537
+ let raf = 0;
17538
+ let dead = false;
17539
+ let idleSince = 0;
17540
+ let dragging = null;
17541
+ let ready = false;
17542
+ const voice = petVoice(typeof window !== "undefined" ? window : {}, pet.sounds, cfg || {});
17543
+ let activity = false;
17544
+ let activityTimer = null;
17545
+ /** 一次性动作(done/fail)播完自动回 idle 的时间戳。 */
17546
+ let holdUntil = 0;
17547
+
17548
+ const cw = Math.max(1, Number(pet.cell && pet.cell.width) || 192);
17549
+ const ch = Math.max(1, Number(pet.cell && pet.cell.height) || 208);
17550
+ const cols = Math.max(1, Number(pet.columns) || 8);
17551
+ const frames = Array.isArray(pet.frames) && pet.frames.length ? pet.frames : new Array(9).fill(cols);
17552
+
17553
+ function rowFrames(r) {
17554
+ const n = Number(frames[r]);
17555
+ return Number.isFinite(n) && n > 0 ? Math.min(cols, n) : cols;
17556
+ }
17557
+ function frameMs(r, f) {
17558
+ const t = pet.tracks && pet.tracks[Object.keys(PET_ROW)[r] === undefined ? "" : Object.keys(PET_ROW)[r]];
17559
+ const d = t && Array.isArray(t.durations) ? t.durations[f] : null;
17560
+ return Number.isFinite(Number(d)) && Number(d) > 0 ? Number(d) : PET_DEFAULT_FRAME_MS;
17561
+ }
17562
+
17563
+ function resize() {
17564
+ const w = Math.round(cw * scale);
17565
+ const h = Math.round(ch * scale);
17566
+ const dpr = Math.min(3, Math.max(1, (typeof window !== "undefined" && window.devicePixelRatio) || 1));
17567
+ canvas.width = Math.round(w * dpr);
17568
+ canvas.height = Math.round(h * dpr);
17569
+ canvas.style.width = w + "px";
17570
+ canvas.style.height = h + "px";
17571
+ if (canvas.getContext) {
17572
+ const c2 = canvas.getContext("2d");
17573
+ if (c2 && c2.setTransform) c2.setTransform(dpr, 0, 0, dpr, 0, 0);
17574
+ }
17575
+ }
17576
+
17577
+ function draw() {
17578
+ if (!ready || dead) return;
17579
+ const n = rowFrames(row);
17580
+ if (frame >= n) frame = 0;
17581
+ const sx = frame * cw;
17582
+ const sy = row * ch;
17583
+ try {
17584
+ ctx.clearRect(0, 0, cw * scale, ch * scale);
17585
+ ctx.drawImage(img, sx, sy, cw, ch, 0, 0, cw * scale, ch * scale);
17586
+ } catch { /* 贴图还没解码完:下一帧再画 */ }
17587
+ }
17588
+
17589
+ function tick(now) {
17590
+ if (dead) return;
17591
+ if (!last) last = now;
17592
+ acc += now - last;
17593
+ last = now;
17594
+ // 活动状态变化时先切行(work/done/fail 优先于 idle/wave)。
17595
+ if (activityTimer === null && typeof setInterval === "function") {
17596
+ activityTimer = setInterval(() => {
17597
+ const nowActive = petActivityProbe();
17598
+ if (nowActive === activity && now < holdUntil) return;
17599
+ activity = nowActive;
17600
+ if (activity) {
17601
+ holdUntil = 0;
17602
+ if (row !== PET_ROW.work) setRow(PET_ROW.work);
17603
+ } else if (row === PET_ROW.work) {
17604
+ // 干完活 → 完成行一小会儿,再回 idle("被看见"的反馈)
17605
+ setRow(PET_ROW.done);
17606
+ holdUntil = Date.now() + 1600;
17607
+ voice.play("done");
17608
+ }
17609
+ }, PET_ACTIVITY_POLL_MS);
17610
+ }
17611
+ if (holdUntil && now >= holdUntil) { holdUntil = 0; setRow(PET_ROW.idle); }
17612
+ const ms = frameMs(row, frame);
17613
+ if (acc >= ms) {
17614
+ acc = 0;
17615
+ frame += 1;
17616
+ if (frame >= rowFrames(row)) {
17617
+ frame = 0;
17618
+ // 一次性动作(挥手 / 跳)播完回 idle
17619
+ if (row === PET_ROW.wave || row === PET_ROW.jump) row = PET_ROW.idle;
17620
+ // done / fail 由 holdUntil 计时收尾,不在这里回 idle(否则刚出现的反馈会被吞掉)。
17621
+ }
17622
+ }
17623
+ draw();
17624
+ raf = (typeof requestAnimationFrame === "function")
17625
+ ? requestAnimationFrame(tick)
17626
+ : setTimeout(() => tick(Date.now()), 60);
17627
+ }
17628
+
17629
+ function setRow(next) {
17630
+ if (row === next) return;
17631
+ row = next;
17632
+ frame = 0;
17633
+ acc = 0;
17634
+ }
17635
+
17636
+ function onPointerDown(e) {
17637
+ if (!layerEl) return;
17638
+ dragging = {
17639
+ dx: e.clientX - layerEl.offsetLeft,
17640
+ dy: e.clientY - layerEl.offsetTop,
17641
+ moved: false,
17642
+ };
17643
+ try { canvas.setPointerCapture && canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ }
17644
+ e.preventDefault();
17645
+ }
17646
+ function onPointerMove(e) {
17647
+ if (!dragging) return;
17648
+ const x = Math.max(0, Math.min((window.innerWidth || 1920) - layerEl.offsetWidth, e.clientX - dragging.dx));
17649
+ const y = Math.max(0, Math.min((window.innerHeight || 1080) - layerEl.offsetHeight, e.clientY - dragging.dy));
17650
+ layerEl.style.left = x + "px";
17651
+ layerEl.style.top = y + "px";
17652
+ layerEl.style.right = "auto";
17653
+ layerEl.style.bottom = "auto";
17654
+ dragging.moved = true;
17655
+ }
17656
+ function onPointerUp() {
17657
+ if (!dragging) return;
17658
+ const moved = dragging.moved;
17659
+ dragging = null;
17660
+ if (!moved) {
17661
+ // 单击 = 挥手(给用户一个"它有反应"的反馈),之后自动回 idle
17662
+ setRow(PET_ROW.wave);
17663
+ voice.play("wave");
17664
+ } else {
17665
+ try { localStorage.setItem("wePetPos", JSON.stringify({ left: layerEl.style.left, top: layerEl.style.top })); } catch { /* ignore */ }
17666
+ }
17667
+ }
17668
+
17669
+ img.onload = () => { ready = true; resize(); draw(); };
17670
+ img.onerror = () => { destroy(); };
17671
+ img.src = apiUrl(pet.sheet || "/pets/sheet?v=" + encodeURIComponent(pet.dirId || pet.id));
17672
+
17673
+ canvas.addEventListener("pointerdown", onPointerDown);
17674
+ if (typeof window !== "undefined") {
17675
+ window.addEventListener("pointermove", onPointerMove);
17676
+ window.addEventListener("pointerup", onPointerUp);
17677
+ }
17678
+ idleSince = Date.now();
17679
+ raf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(tick) : setTimeout(() => tick(Date.now()), 60);
17680
+
17681
+ function destroy() {
17682
+ dead = true;
17683
+ if (typeof clearInterval === "function" && activityTimer !== null) clearInterval(activityTimer);
17684
+ activityTimer = null;
17685
+ try { voice.destroy(); } catch { /* ignore */ }
17686
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf); else clearTimeout(raf);
17687
+ canvas.removeEventListener("pointerdown", onPointerDown);
17688
+ if (typeof window !== "undefined") {
17689
+ window.removeEventListener("pointermove", onPointerMove);
17690
+ window.removeEventListener("pointerup", onPointerUp);
17691
+ }
17692
+ }
17693
+
17694
+ return {
17695
+ destroy, setRow, resize,
17696
+ /** 外部事件注入(面板操作失败等):一次性播放失败行并出声。 */
17697
+ fail() { setRow(PET_ROW.fail); holdUntil = Date.now() + 1600; voice.play("fail"); },
17698
+ cheer() { setRow(PET_ROW.done); holdUntil = Date.now() + 1600; voice.play("done"); },
17699
+ get row() { return row; },
17700
+ };
17701
+ }
17702
+
17703
+ /**
17704
+ * 挂载桌宠层。返回卸载函数(无宠物 / 未启用时返回 null)。
17705
+ *
17706
+ * 它自己拉 `/folia/pets/list`(配置 + 已装宠物),所以**不依赖面板是否打开过** ——
17707
+ * 冷启动就能出宠物。
17708
+ */
17709
+ function mountPetLayer() {
17710
+ if (typeof document === "undefined" || !document.body) return null;
17711
+ let disposed = false;
17712
+ let instance = null;
17713
+ let layerEl = null;
17714
+
17715
+ function teardownDom() {
17716
+ if (instance) { try { instance.destroy(); } catch { /* ignore */ } instance = null; }
17717
+ if (layerEl && layerEl.parentNode) layerEl.parentNode.removeChild(layerEl);
17718
+ layerEl = null;
17719
+ }
17720
+
17721
+ async function sync() {
17722
+ let doc = null;
17723
+ try {
17724
+ const resp = await apiJson("/pets/list", { parse: "always" });
17725
+ doc = (resp && resp.data) || null;
17726
+ } catch { doc = null; }
17727
+ if (disposed) return;
17728
+ const cfg = (doc && doc.config) || null;
17729
+ const pets = (doc && doc.pets) || [];
17730
+ const pet = cfg && cfg.petId ? pets.find((p) => p.dirId === cfg.petId || p.id === cfg.petId) : null;
17731
+ if (!cfg || !cfg.enabled || !pet) { teardownDom(); return; }
17732
+
17733
+ const scale = Number(cfg.scale) > 0 ? Number(cfg.scale) : 1;
17734
+ if (layerEl) { layerEl.style.display = ""; if (instance) instance.resize(); return; }
17735
+
17736
+ layerEl = document.createElement("div");
17737
+ layerEl.id = PET_LAYER_ID;
17738
+ layerEl.style.cssText = [
17739
+ "position:fixed", "right:24px", "bottom:16px", "z-index:2147482000",
17740
+ "cursor:grab", "user-select:none", "touch-action:none",
17741
+ "filter:drop-shadow(0 6px 14px rgba(0,0,0,0.35))",
17742
+ ].join(";");
17743
+ try {
17744
+ const saved = JSON.parse(localStorage.getItem("wePetPos") || "null");
17745
+ if (saved && saved.left && saved.top) {
17746
+ layerEl.style.left = saved.left;
17747
+ layerEl.style.top = saved.top;
17748
+ layerEl.style.right = "auto";
17749
+ layerEl.style.bottom = "auto";
17750
+ }
17751
+ } catch { /* 用默认位置 */ }
17752
+ const canvas = document.createElement("canvas");
17753
+ layerEl.appendChild(canvas);
17754
+ document.body.appendChild(layerEl);
17755
+ instance = createPetLayer(layerEl, canvas, pet, scale, cfg);
17756
+ }
17757
+
17758
+ sync();
17759
+ // 配置变化(面板里换宠物/开关)不需要重启:轻量轮询即可,代价是一次本地 HTTP。
17760
+ // ⚠️ 定时器要 feature-detect:宿主在 smoke / 单测沙箱里没有 timers,直接调会 ReferenceError,
17761
+ // 而那一抛会把整个 apply 打成失败(实测:smoke 第 1 步就红)。
17762
+ const timer = typeof setInterval === 'function'
17763
+ ? setInterval(() => { if (!disposed) sync(); }, 4000)
17764
+ : null;
17765
+
17766
+ return () => {
17767
+ disposed = true;
17768
+ if (typeof clearInterval === 'function' && timer !== null) clearInterval(timer);
17769
+ teardownDom();
17770
+ };
17771
+ }
17772
+
17183
17773
  // ── 内联模块:src/quick-panel.js —— 快捷播放面板(官方侧栏 tab 与低版本抽屉共用同一份内容):当前壁纸 + 轮播 + 列表快切 + 声音 + 设置入口。列表虚拟滚动(spacer + 可视窗口)(构建期注入;勿手改本段)──
17184
17774
  /**
17185
17775
  * quick-panel.js — 快捷播放面板(侧边栏的**唯一**内容):当前壁纸 + 轮播 + 三档页签
@@ -17355,6 +17945,8 @@ window.__ModuleLoader__.load({
17355
17945
  { id: "wallpaper", get label() { return weT("壁纸"); } },
17356
17946
  { id: "appearance", get label() { return weT("外观"); } },
17357
17947
  { id: "playback", get label() { return weT("播放"); } },
17948
+ // 桌宠是"桌面上多出来的一只东西",与壁纸/开机动画并列,不是它们的子功能。
17949
+ { id: "pets", get label() { return weT("桌宠"); } },
17358
17950
  ];
17359
17951
  // 页签记忆(同 picker-tab / qp-view 口径:仅 UI 状态,localStorage,不进 config.json)。
17360
17952
  const QP_TAB_KEY = "dsh-folia:qp-tab";
@@ -18069,6 +18661,7 @@ window.__ModuleLoader__.load({
18069
18661
  && React.createElement("div", { className: "we-qp__propsview we-qp__propsview--drill" }, renderUserPropsPanel()),
18070
18662
  // 开机动画档:整区就是那个面板(它自己管加载/下载/开关)。
18071
18663
  qpTab === "boot" && React.createElement(BootTabPanel, null),
18664
+ qpTab === "pets" && React.createElement(PetsTabPanel, null),
18072
18665
  !(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && qpTab !== "boot" && (qpTab === "appearance"
18073
18666
  ? qpRenderAppearancePane(sel, {
18074
18667
  fontOpen, onToggleFontSection: toggleFontSection,
@@ -31401,6 +31994,10 @@ window.__ModuleLoader__.load({
31401
31994
  if (ctx.effect && typeof document !== "undefined") {
31402
31995
  ctx.effect(() => installSkinInterop() || undefined);
31403
31996
  }
31997
+ // 3c. 桌宠层:自己拉 /pets/list 决定是否出宠物,随 fiber 注销(不依赖面板打开过)。
31998
+ if (ctx.effect && typeof document !== "undefined") {
31999
+ ctx.effect(() => mountPetLayer() || undefined);
32000
+ }
31404
32001
 
31405
32002
  // 3. Chat-interface rope dock: the draggable pull-cord + glass repo side
31406
32003
  // panel, portalled onto <body> under its own React root — independent of
package/lib/index.js CHANGED
@@ -81,6 +81,7 @@ import { registerInventoryRoutes, warmInventorySiteCache } from './routes/invent
81
81
  import { registerModulesRoutes, warmModulesCache } from './routes/modules.js';
82
82
  import { registerBootRoutes } from './routes/boot.js';
83
83
  import { registerSiteThumbRoutes } from './routes/site-thumb.js';
84
+ import { registerPetsRoutes } from './routes/pets.js';
84
85
  import { installBootInjection } from './boot/index.js';
85
86
  import { registerDiagRoutes } from './routes/diag.js';
86
87
  // 遮挡消息桥族(上游新增域模块):hub 处理 x11 遮挡探针,路由对外暴露状态/控制面。
@@ -309,6 +310,9 @@ export function apply(ctx) {
309
310
  registerSiteThumbRoutes(webServer, { disposers, base: BASE, ensureImagePreview, log });
310
311
 
311
312
  // 启动预热(有意不阻塞):站点清单与模块索引各拉一次,让第一次打开面板不冷。
313
+ // 桌宠:自渲染(不依赖第三方宠物插件)—— 站点当分发通道,我们负责安装与绘制。
314
+ registerPetsRoutes(webServer, { disposers, base: BASE, log });
315
+
312
316
  warmInventorySiteCache();
313
317
  warmModulesCache();
314
318
  installBootInjection(ctx, (m) => { try { log(m); } catch { /* ignore */ } });
@@ -0,0 +1,459 @@
1
+ /**
2
+ * routes/pets.js — **桌宠**(自渲染,不依赖第三方宠物插件)。
3
+ *
4
+ * 为什么自己渲染:桌宠的本质只是"一张雪碧图 + 每行的帧数/每帧时长 + 一个状态机",
5
+ * 属于纯前端的绘帧问题,没有必须借助外部渲染器的理由。站点当分发通道,插件自己画。
6
+ *
7
+ * 数据契约(与 @linxin666/dsh-pet 的 manifest v2 兼容,方便复用现成宠物资产):
8
+ * <数据目录>/pets/<id>/pet.json 必填,见下
9
+ * <数据目录>/pets/<id>/spritesheet.webp 必填
10
+ *
11
+ * 支持的最小形态(sprite2d):
12
+ * { petManifestVersion: 2, id, displayName, renderer: "sprite2d",
13
+ * sprite2d: { spritesheetPath, cell: { width, height }, columns,
14
+ * frames?: number[], tracks?: { <状态>: { durations: number[] } } } }
15
+ * 几何缺省值按 dsh-pet 的 contract 走:8 列 × 9 行,每格 192×208。
16
+ *
17
+ * 纪律:
18
+ * · **只读我们自己的目录**(`<数据目录>/pets`),不碰别的插件的资源目录;
19
+ * · 安装只认带 `pet.json` 且 spritesheet 真实存在的目录,缺一不可;
20
+ * · 任何异常都折成 4xx + 明确错误码,绝不把宿主打 500。
21
+ */
22
+ import { createHash } from 'node:crypto';
23
+ import { inflateRawSync } from 'node:zlib';
24
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { basename, dirname, extname, join } from 'node:path';
27
+
28
+ /** 注入契约(ADR-0013 D12)。 */
29
+ export { unzip };
30
+ export const DEPS = ['disposers', 'base', 'log'];
31
+
32
+ const DEFAULT_COLUMNS = 8;
33
+ const DEFAULT_ROWS = 9;
34
+ const DEFAULT_CELL = { width: 192, height: 208 };
35
+ const SHEET_MAX_BYTES = 24 * 1024 * 1024;
36
+ const FETCH_TIMEOUT_MS = 30000;
37
+
38
+ function dataRoot() {
39
+ const base = process.env.DSH_FOLIA_HOME || join(homedir(), '.dsh-folia');
40
+ return join(base, 'pets');
41
+ }
42
+
43
+ function siteBase() {
44
+ const raw = process.env.DSH_FOLIA_SITE;
45
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
46
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : 'https://folia-5ve.pages.dev';
47
+ return base.replace(/\/+$/, '');
48
+ }
49
+
50
+ /** 站点上的桌宠分类 slug(站点 `/api/public/categories` 里就有这一条)。 */
51
+ const PET_CATEGORY = 'desktop-pet';
52
+
53
+ function ensureDir(p) {
54
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
55
+ return p;
56
+ }
57
+
58
+ /**
59
+ * 一只已安装宠物的完整描述;`null` 表示这目录不是合法宠物(必须是**整份**:
60
+ * pet.json + 雪碧图都存在)。宁可少认一只,也不要在前端画出空白方块。
61
+ */
62
+ function readPet(dir, id) {
63
+ const manifestPath = join(dir, 'pet.json');
64
+ let manifest;
65
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return null; }
66
+ if (!manifest || typeof manifest !== 'object') return null;
67
+ const g = (manifest.sprite2d && typeof manifest.sprite2d === 'object') ? manifest.sprite2d : {};
68
+ const sheetName = typeof g.spritesheetPath === 'string' ? basename(g.spritesheetPath) : 'spritesheet.webp';
69
+ const sheetPath = join(dir, sheetName);
70
+ if (!existsSync(sheetPath)) return null;
71
+ const cell = (g.cell && Number(g.cell.width) > 0 && Number(g.cell.height) > 0)
72
+ ? { width: Number(g.cell.width), height: Number(g.cell.height) } : { ...DEFAULT_CELL };
73
+ const columns = Number(g.columns) > 0 ? Number(g.columns) : DEFAULT_COLUMNS;
74
+ const frames = Array.isArray(g.frames) && g.frames.length === DEFAULT_ROWS
75
+ ? g.frames.map((n) => Math.max(1, Math.min(columns, Number(n) || columns)))
76
+ : new Array(DEFAULT_ROWS).fill(columns);
77
+ // 轨道:只保留显式给出且长度合理的 durations;其余由前端用默认节奏。
78
+ const tracks = {};
79
+ if (g.tracks && typeof g.tracks === 'object') {
80
+ for (const [k, v] of Object.entries(g.tracks)) {
81
+ if (v && Array.isArray(v.durations) && v.durations.length) {
82
+ tracks[k] = { durations: v.durations.map((d) => Math.max(16, Number(d) || 120)) };
83
+ }
84
+ }
85
+ }
86
+ return {
87
+ id: String(manifest.id || id),
88
+ dirId: id,
89
+ name: String(manifest.displayName || manifest.id || id),
90
+ author: manifest.author ? String(manifest.author) : null,
91
+ license: manifest.license ? String(manifest.license) : null,
92
+ renderer: String(manifest.renderer || 'sprite2d'),
93
+ bytes: (() => { try { return statSync(sheetPath).size; } catch { return null; } })(),
94
+ cell, columns, frames, tracks,
95
+ sheet: '/folia/pets/sheet?v=' + encodeURIComponent(id),
96
+ // 音效:manifest 里显式给了 `sounds`(事件 → 包内文件)才生效;
97
+ // 没有就由前端用 Web Audio 合成,避免"没声音"变成默认行为。
98
+ sounds: (() => {
99
+ const src = (manifest.sounds && typeof manifest.sounds === 'object') ? manifest.sounds : null;
100
+ if (!src) return null;
101
+ const out = {};
102
+ for (const [ev, file] of Object.entries(src)) {
103
+ if (typeof file !== 'string') continue;
104
+ const rel = file.replace(/\\/g, '/');
105
+ if (!/^sounds\/[^/]+\.(ogg|mp3|wav|m4a|webm)$/i.test(rel)) continue;
106
+ const abs = join(dir, rel);
107
+ if (!existsSync(abs)) continue;
108
+ // 原样带 sounds/ 前缀:资源路由按 sounds/<文件> 白名单校验,剥掉前缀就对不上了。
109
+ out[ev] = '/folia/pets/asset?v=' + encodeURIComponent(id) + '&f=' + encodeURIComponent(rel);
110
+ }
111
+ return Object.keys(out).length ? out : null;
112
+ })(),
113
+ };
114
+ }
115
+
116
+ /** 列出本地已安装的宠物(目录扫描;坏目录静默跳过)。 */
117
+ function listInstalled() {
118
+ const root = dataRoot();
119
+ const out = [];
120
+ let names = [];
121
+ try { names = readdirSync(root); } catch { return out; }
122
+ for (const name of names) {
123
+ const dir = join(root, name);
124
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
125
+ const pet = readPet(dir, name);
126
+ if (pet) out.push(pet);
127
+ }
128
+ out.sort((a, b) => a.id.localeCompare(b.id));
129
+ return out;
130
+ }
131
+
132
+ /** 桌面宠物的配置(我们自己的文件,不写别人的配置)。 */
133
+ function configPath() { return join(dirname(dataRoot()), 'pet-config.json'); }
134
+ function readConfig() {
135
+ try {
136
+ const raw = JSON.parse(readFileSync(configPath(), 'utf8'));
137
+ return {
138
+ enabled: raw.enabled === true,
139
+ petId: typeof raw.petId === 'string' ? raw.petId : '',
140
+ scale: Number.isFinite(Number(raw.scale)) ? Math.min(3, Math.max(0.5, Number(raw.scale))) : 1,
141
+ sound: raw.sound === true,
142
+ volume: Number.isFinite(Number(raw.volume)) ? Math.min(1, Math.max(0, Number(raw.volume))) : 0.6,
143
+ };
144
+ } catch {
145
+ return { enabled: false, petId: '', scale: 1, sound: false, volume: 0.6 };
146
+ }
147
+ }
148
+ function writeConfig(next) {
149
+ const cur = readConfig();
150
+ const merged = {
151
+ enabled: next.enabled === undefined ? cur.enabled : Boolean(next.enabled),
152
+ petId: next.petId === undefined ? cur.petId : String(next.petId || ''),
153
+ scale: next.scale === undefined ? cur.scale : Math.min(3, Math.max(0.5, Number(next.scale) || 1)),
154
+ sound: next.sound === undefined ? cur.sound : Boolean(next.sound),
155
+ volume: next.volume === undefined ? cur.volume : Math.min(1, Math.max(0, Number(next.volume) || 0)),
156
+ };
157
+ try {
158
+ ensureDir(dirname(configPath()));
159
+ writeFileSync(configPath(), JSON.stringify(merged, null, 2) + '\n');
160
+ } catch { /* 只读盘:本次配置不落盘,但内存里仍按 merged 回给调用方 */ }
161
+ return merged;
162
+ }
163
+
164
+ function sendJson(res, status, body) {
165
+ const text = JSON.stringify(body);
166
+ res.statusCode = status;
167
+ if (typeof res.setHeader === 'function') {
168
+ res.setHeader('content-type', 'application/json; charset=utf-8');
169
+ res.setHeader('cache-control', 'no-store');
170
+ }
171
+ res.end(text);
172
+ }
173
+
174
+ /**
175
+ * 极简 zip 读取器:只做**取条目**这一件事(store / deflate 两种方法)。
176
+ *
177
+ * 为什么不引依赖:宠物包在我们自己手里生成(见 tools/upload-pets.mjs,store 模式),
178
+ * 而站点上别人上传的包也只有这两类方法。整个解析靠中央目录遍历,不猜、不流式、
179
+ * 且对每个条目做**大小与路径安全**检查 —— 一个 zip 解析器最该防的是路径穿越与 zip bomb。
180
+ *
181
+ * @returns {Map<string, Buffer>} 文件名 → 内容(只保留通过检查的条目)
182
+ */
183
+ function unzip(buf, opts = {}) {
184
+ const maxEntry = Number(opts.maxEntry) || 24 * 1024 * 1024;
185
+ const maxTotal = Number(opts.maxTotal) || 64 * 1024 * 1024;
186
+ const out = new Map();
187
+ let total = 0;
188
+ // EOCD 在文件尾 22 字节起,最多带 64KB 注释
189
+ let eocd = -1;
190
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
191
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
192
+ }
193
+ if (eocd < 0) throw new Error('not-a-zip');
194
+ const count = buf.readUInt16LE(eocd + 10);
195
+ let p = buf.readUInt32LE(eocd + 16);
196
+ for (let n = 0; n < count; n++) {
197
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('bad-central');
198
+ const method = buf.readUInt16LE(p + 10);
199
+ const compSize = buf.readUInt32LE(p + 20);
200
+ const nameLen = buf.readUInt16LE(p + 28);
201
+ const extraLen = buf.readUInt16LE(p + 30);
202
+ const commentLen = buf.readUInt16LE(p + 32);
203
+ const localOff = buf.readUInt32LE(p + 42);
204
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
205
+ p += 46 + nameLen + extraLen + commentLen;
206
+ // 路径安全:只收「根文件」与「sounds/<文件>」两种形态 —— 任何 `..`、绝对路径、
207
+ // 其它子目录一律丢弃。宠物包只需要雪碧图/清单/音效三类东西,收窄比放行安全。
208
+ const safeName = String(name).replace(/\\/g, '/');
209
+ const okShape = safeName.includes('..') || safeName.startsWith('/') ? false
210
+ : (safeName.indexOf('/') === -1 || /^sounds\/[^/]+$/.test(safeName));
211
+ if (!safeName || !okShape) continue;
212
+ if (compSize > maxEntry) continue;
213
+ const lNameLen = buf.readUInt16LE(localOff + 26);
214
+ const lExtraLen = buf.readUInt16LE(localOff + 28);
215
+ const dataAt = localOff + 30 + lNameLen + lExtraLen;
216
+ const comp = buf.subarray(dataAt, dataAt + compSize);
217
+ if (comp.length !== compSize) continue;
218
+ let data;
219
+ try {
220
+ data = method === 0 ? Buffer.from(comp) : method === 8 ? inflateRawSync(comp) : null;
221
+ } catch { data = null; }
222
+ if (!data) continue;
223
+ total += data.length;
224
+ if (total > maxTotal) throw new Error('too-large');
225
+ out.set(name, data);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function readBodyText(req, maxBytes = 64 * 1024) {
231
+ return new Promise((resolve) => {
232
+ let size = 0;
233
+ const chunks = [];
234
+ req.on('data', (c) => {
235
+ size += c.length;
236
+ if (size > maxBytes) { resolve(''); req.destroy(); return; }
237
+ chunks.push(c);
238
+ });
239
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
240
+ req.on('error', () => resolve(''));
241
+ });
242
+ }
243
+
244
+ export function registerPetsRoutes(webServer, c) {
245
+ const { disposers, base: BASE, log } = c;
246
+
247
+ // ── 站点桌宠清单(分类 desktop-pet) ──────────────────────────────────────
248
+ let siteCache = { at: 0, items: [], error: null };
249
+ async function fetchSitePets(force) {
250
+ if (!force && siteCache.items.length && Date.now() - siteCache.at < 60000) return siteCache;
251
+ const base = siteBase();
252
+ if (!base) { siteCache = { at: Date.now(), items: [], error: 'site-off' }; return siteCache; }
253
+ try {
254
+ const r = await fetch(base + '/api/public/wallpapers?category=' + PET_CATEGORY + '&limit=100', {
255
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
256
+ });
257
+ if (!r.ok) throw new Error('http-' + r.status);
258
+ const doc = await r.json();
259
+ const list = (doc && doc.data && Array.isArray(doc.data.items)) ? doc.data.items : [];
260
+ const items = list.map((it) => {
261
+ const url = String((it && it.url) || '');
262
+ const abs = /^https?:/i.test(url) ? url : (url ? base + url : '');
263
+ const thumb = String((it && it.thumbUrl) || '');
264
+ return {
265
+ id: String((it && (it.slug || it.id)) || ''),
266
+ title: String((it && it.title) || ''),
267
+ bytes: Number(it && it.bytes) || null,
268
+ url: abs,
269
+ cover: thumb && /\.(jpg|jpeg|png|webp|gif|avif)(\?|#|$)/i.test(thumb)
270
+ ? (/^https?:/i.test(thumb) ? thumb : base + thumb) : null,
271
+ };
272
+ }).filter((x) => x.id && x.url);
273
+ siteCache = { at: Date.now(), items, error: null };
274
+ return siteCache;
275
+ } catch (e) {
276
+ siteCache = { at: Date.now(), items: siteCache.items, error: String((e && e.message) || e) };
277
+ return siteCache;
278
+ }
279
+ }
280
+
281
+ // ── GET /pets/list ────────────────────────────────────────────────────────
282
+ disposers.push(webServer.register({
283
+ kind: 'exact',
284
+ path: `${BASE}/pets/list`,
285
+ handler: async (req, res) => {
286
+ const url = new URL(req.url || '/', 'http://x');
287
+ const force = url.searchParams.get('refresh') === '1';
288
+ const site = await fetchSitePets(force);
289
+ const installed = listInstalled();
290
+ const installedIds = new Set(installed.map((p) => p.dirId));
291
+ sendJson(res, 200, {
292
+ ok: true,
293
+ pets: installed,
294
+ site: site.items.map((it) => ({ ...it, installed: installedIds.has(it.id) })),
295
+ siteError: site.error,
296
+ config: readConfig(),
297
+ });
298
+ },
299
+ }));
300
+
301
+ // ── GET /pets/sheet?v=<dirId>(本地雪碧图) ───────────────────────────────
302
+ disposers.push(webServer.register({
303
+ kind: 'exact',
304
+ path: `${BASE}/pets/sheet`,
305
+ handler: async (req, res) => {
306
+ let id = '';
307
+ try { id = new URL(req.url || '/', 'http://x').searchParams.get('v') || ''; } catch { id = ''; }
308
+ const dir = join(dataRoot(), String(id));
309
+ const pet = id && existsSync(dir) ? readPet(dir, id) : null;
310
+ if (!pet) { res.statusCode = 404; res.end(); return; }
311
+ const manifest = (() => { try { return JSON.parse(readFileSync(join(dir, 'pet.json'), 'utf8')); } catch { return {}; } })();
312
+ const sheetName = basename((manifest.sprite2d && manifest.sprite2d.spritesheetPath) || 'spritesheet.webp');
313
+ const file = join(dir, sheetName);
314
+ let size = 0;
315
+ try { size = statSync(file).size; } catch { /* 下面统一 404 */ }
316
+ if (!size || size > SHEET_MAX_BYTES) { res.statusCode = 404; res.end(); return; }
317
+ const type = extname(file).toLowerCase() === '.png' ? 'image/png'
318
+ : extname(file).toLowerCase() === '.gif' ? 'image/gif' : 'image/webp';
319
+ if (typeof res.setHeader === 'function') {
320
+ res.setHeader('content-type', type);
321
+ res.setHeader('content-length', String(size));
322
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
323
+ }
324
+ res.statusCode = 200;
325
+ createReadStream(file).pipe(res);
326
+ },
327
+ }));
328
+
329
+ // ── GET /pets/asset?v=<dirId>&f=<相对路径>(包内音频等额外资源) ───────────
330
+ disposers.push(webServer.register({
331
+ kind: 'exact',
332
+ path: `${BASE}/pets/asset`,
333
+ handler: async (req, res) => {
334
+ let q = null;
335
+ try { q = new URL(req.url || '/', 'http://x').searchParams; } catch { q = null; }
336
+ const id = q ? String(q.get('v') || '') : '';
337
+ const rel = q ? String(q.get('f') || '').replace(/\\/g, '/') : '';
338
+ if (!id || /[\\/]/.test(id) || id.includes('..') || !rel) { res.statusCode = 400; res.end(); return; }
339
+ // 只放行 sounds/ 下的已知音频扩展名:这条路由不充当通用文件读口。
340
+ if (!/^sounds\/[^/]+\.(ogg|mp3|wav|m4a|webm)$/i.test(rel)) { res.statusCode = 400; res.end(); return; }
341
+ const file = join(dataRoot(), id, rel);
342
+ let size = 0;
343
+ try { size = statSync(file).size; } catch { size = 0; }
344
+ if (!size || size > 16 * 1024 * 1024) { res.statusCode = 404; res.end(); return; }
345
+ const ext = extname(file).toLowerCase();
346
+ const type = ext === '.mp3' ? 'audio/mpeg' : ext === '.wav' ? 'audio/wav'
347
+ : ext === '.m4a' ? 'audio/mp4' : ext === '.webm' ? 'audio/webm' : 'audio/ogg';
348
+ if (typeof res.setHeader === 'function') {
349
+ res.setHeader('content-type', type);
350
+ res.setHeader('content-length', String(size));
351
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
352
+ }
353
+ res.statusCode = 200;
354
+ createReadStream(file).pipe(res);
355
+ },
356
+ }));
357
+
358
+ // ── POST /pets/config(开关 / 选中 / 缩放) ───────────────────────────────
359
+ disposers.push(webServer.register({
360
+ kind: 'exact',
361
+ path: `${BASE}/pets/config`,
362
+ handler: async (req, res) => {
363
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
364
+ const text = await readBodyText(req);
365
+ let patch = {};
366
+ try { patch = JSON.parse(text || '{}'); } catch { sendJson(res, 400, { ok: false, error: 'bad-json' }); return; }
367
+ if (patch.petId && !existsSync(join(dataRoot(), String(patch.petId)))) {
368
+ sendJson(res, 404, { ok: false, error: 'not-installed' });
369
+ return;
370
+ }
371
+ const cfg = writeConfig(patch);
372
+ sendJson(res, 200, { ok: true, config: cfg });
373
+ },
374
+ }));
375
+
376
+ // ── POST /pets/install(从站点条目安装:下载 zip → 解开 → 校验) ─────────
377
+ disposers.push(webServer.register({
378
+ kind: 'exact',
379
+ path: `${BASE}/pets/install`,
380
+ handler: async (req, res) => {
381
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
382
+ const text = await readBodyText(req);
383
+ let id = '';
384
+ try { id = String((JSON.parse(text || '{}') || {}).id || '').trim(); } catch { id = ''; }
385
+ if (!id || /[\\/]/.test(id) || id.includes('..') || id.length > 120) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
386
+
387
+ const site = await fetchSitePets(false);
388
+ const item = site.items.find((x) => x.id === id);
389
+ if (!item) { sendJson(res, 404, { ok: false, error: 'not-on-site', detail: site.error || null }); return; }
390
+
391
+ let buf;
392
+ try {
393
+ const r = await fetch(item.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
394
+ if (!r.ok) { sendJson(res, 502, { ok: false, error: 'http-' + r.status }); return; }
395
+ buf = Buffer.from(await r.arrayBuffer());
396
+ } catch (e) { sendJson(res, 502, { ok: false, error: 'fetch-failed', detail: String((e && e.message) || e) }); return; }
397
+
398
+ if (buf.length < 64 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
399
+ sendJson(res, 422, { ok: false, error: 'not-a-zip' });
400
+ return;
401
+ }
402
+
403
+ let entries;
404
+ try { entries = unzip(buf); } catch (e) { sendJson(res, 422, { ok: false, error: 'bad-zip', detail: String((e && e.message) || e) }); return; }
405
+ const manifestRaw = entries.get('pet.json');
406
+ if (!manifestRaw) { sendJson(res, 422, { ok: false, error: 'no-manifest' }); return; }
407
+ let manifest;
408
+ try { manifest = JSON.parse(manifestRaw.toString('utf8')); } catch { sendJson(res, 422, { ok: false, error: 'bad-manifest' }); return; }
409
+ const sheetName = basename((manifest.sprite2d && manifest.sprite2d.spritesheetPath) || 'spritesheet.webp');
410
+ const sheet = entries.get(sheetName);
411
+ if (!sheet) { sendJson(res, 422, { ok: false, error: 'no-spritesheet', detail: sheetName }); return; }
412
+
413
+ const dir = join(dataRoot(), id);
414
+ try {
415
+ ensureDir(dir);
416
+ for (const [name, data] of entries) {
417
+ const rel = String(name).replace(/\\/g, '/');
418
+ const safe = rel.indexOf('/') === -1 ? basename(rel) : rel; // sounds/x.ogg 保留一层
419
+ const target = join(dir, safe);
420
+ ensureDir(dirname(target));
421
+ writeFileSync(target, data);
422
+ }
423
+ } catch (e) {
424
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
425
+ sendJson(res, 500, { ok: false, error: 'write-failed', detail: String((e && e.message) || e) });
426
+ return;
427
+ }
428
+
429
+ const pet = readPet(dir, id);
430
+ if (!pet) { sendJson(res, 422, { ok: false, error: 'install-invalid' }); return; }
431
+ // 装完就选中它并开启 —— 用户点「安装」的意图就是「我要用它」。
432
+ const cfg = writeConfig({ enabled: true, petId: id });
433
+ if (typeof log === 'function') log('[pets] installed ' + id + ' (' + buf.length + ' bytes)');
434
+ sendJson(res, 200, { ok: true, id, pet, config: cfg });
435
+ },
436
+ }));
437
+
438
+ // ── POST /pets/remove(只删我们目录下的一只) ─────────────────────────────
439
+ disposers.push(webServer.register({
440
+ kind: 'exact',
441
+ path: `${BASE}/pets/remove`,
442
+ handler: async (req, res) => {
443
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
444
+ const text = await readBodyText(req);
445
+ let id = '';
446
+ try { id = String((JSON.parse(text || '{}') || {}).id || ''); } catch { id = ''; }
447
+ if (!id || /[\\/]/.test(id) || id.includes('..')) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
448
+ const dir = join(dataRoot(), id);
449
+ if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
450
+ try { rmSync(dir, { recursive: true, force: true }); } catch { sendJson(res, 500, { ok: false, error: 'remove-failed' }); return; }
451
+ const cfg = readConfig();
452
+ if (cfg.petId === id) writeConfig({ petId: '' });
453
+ if (typeof log === 'function') log('[pets] removed ' + id);
454
+ sendJson(res, 200, { ok: true, id });
455
+ },
456
+ }));
457
+
458
+ return () => { /* 无长驻资源:路由随 disposers 归还 */ };
459
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.0.8",
3
+ "version": "4.3.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -191,7 +191,8 @@
191
191
  "verify:all": "node test/run-guards.mjs all",
192
192
  "prepack": "node scripts/prepare.mjs",
193
193
  "test:boot": "node test/boot-http.mjs",
194
- "gate": "node scripts/build-client.mjs \u0026\u0026 node test/run-guards.mjs smoke \u0026\u0026 node test/boot-http.mjs"
194
+ "test:pets": "node test/pets-http.mjs",
195
+ "gate": "node scripts/build-client.mjs \u0026\u0026 node test/run-guards.mjs smoke \u0026\u0026 node test/boot-http.mjs \u0026\u0026 node test/pets-http.mjs"
195
196
  },
196
197
  "peerDependencies": {
197
198
  "@deepseek-ai/cordis": "^4.0.4",