dsh-folia 4.0.7 → 4.2.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,437 @@ 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: "4px", alignItems: "center", fontSize: "12px" } },
17286
+ weT("大小"),
17287
+ React.createElement("select", {
17288
+ className: "we-picker__playlist-select",
17289
+ value: String(cfg.scale || 1),
17290
+ disabled: Boolean(busy),
17291
+ onChange: (e) => act("config", { scale: Number(e.target.value) }, ""),
17292
+ },
17293
+ [[0.6, "60%"], [0.8, "80%"], [1, "100%"], [1.3, "130%"], [1.6, "160%"]].map(([v, t]) =>
17294
+ React.createElement("option", { key: String(v), value: String(v) }, t)),
17295
+ ),
17296
+ ),
17297
+ ));
17298
+
17299
+ if (note) {
17300
+ children.push(React.createElement("div", {
17301
+ key: "note",
17302
+ className: "we-picker__hint",
17303
+ style: { color: note.ok ? "rgba(120,220,150,0.95)" : "rgba(255,140,140,0.95)", margin: "2px 0 8px" },
17304
+ }, note.text));
17305
+ }
17306
+
17307
+ const btn = (text, onClick, disabled) => React.createElement("button", {
17308
+ type: "button", className: "we-picker__btn", disabled: Boolean(disabled),
17309
+ style: { flex: "1 1 auto" }, onClick,
17310
+ }, text);
17311
+
17312
+ // 已装:换一只 / 卸载
17313
+ children.push(React.createElement("div", { key: "installed", style: PET_STYLE.grid },
17314
+ pets.length
17315
+ ? pets.map((p) => {
17316
+ const isActive = cfg.petId === p.dirId || cfg.petId === p.id;
17317
+ const key = "inst:" + p.dirId;
17318
+ const actions = [];
17319
+ actions.push(btn(isActive ? weT("使用中") : weT("使用"), () => act("config", { enabled: true, petId: p.dirId }, p.name), isActive || Boolean(busy)));
17320
+ actions.push(btn(weT("卸载"), () => act("remove", { id: p.dirId }, p.name), Boolean(busy)));
17321
+ return React.createElement("div", { key, style: PET_STYLE.card },
17322
+ React.createElement("img", {
17323
+ src: p.sheet, alt: "", loading: "lazy", decoding: "async",
17324
+ style: { width: "100%", maxHeight: "96px", objectFit: "contain", background: "rgba(0,0,0,0.25)", borderRadius: "6px" },
17325
+ onError: (e) => { e.target.style.display = "none"; },
17326
+ }),
17327
+ React.createElement("div", { style: PET_STYLE.title }, p.name),
17328
+ React.createElement("div", { style: PET_STYLE.meta },
17329
+ (p.columns || 8) + weT(" 列 · ") + (p.cell ? p.cell.width + "×" + p.cell.height : "")),
17330
+ React.createElement("div", { style: PET_STYLE.row }, actions),
17331
+ );
17332
+ })
17333
+ : React.createElement("div", { className: "we-picker__hint" }, weT("还没有安装任何桌宠 —— 从下面的站点清单里挑一只。")),
17334
+ ));
17335
+
17336
+ // 站点:未装的可安装
17337
+ const available = site.filter((s) => !installedIds.has(s.id));
17338
+ children.push(React.createElement("div", { key: "siteHead", className: "we-picker__section-head", style: { marginTop: "14px" } },
17339
+ React.createElement("span", { className: "we-picker__section-label" },
17340
+ weT("站点桌宠") + (site.length ? " · " + site.length : "")),
17341
+ state.siteError
17342
+ ? React.createElement("span", { className: "we-picker__hint", style: { marginLeft: "auto" } }, weT("站点不可达"))
17343
+ : null,
17344
+ ));
17345
+
17346
+ if (available.length) {
17347
+ children.push(React.createElement("div", { key: "site", style: PET_STYLE.grid },
17348
+ available.map((s) => {
17349
+ const isBusy = busy === "install:" + s.id;
17350
+ return React.createElement("div", { key: "site:" + s.id, style: PET_STYLE.card },
17351
+ s.cover
17352
+ ? React.createElement("img", {
17353
+ src: s.cover, alt: "", loading: "lazy", decoding: "async",
17354
+ style: { width: "100%", maxHeight: "96px", objectFit: "cover", borderRadius: "6px" },
17355
+ onError: (e) => { e.target.style.display = "none"; },
17356
+ })
17357
+ : null,
17358
+ React.createElement("div", { style: PET_STYLE.title }, s.title || s.id),
17359
+ React.createElement("div", { style: PET_STYLE.meta }, petFmtBytes(s.bytes)),
17360
+ React.createElement("div", { style: PET_STYLE.row },
17361
+ btn(isBusy ? weT("安装中…") : weT("安装"), () => act("install", { id: s.id }, s.title || s.id), Boolean(busy))),
17362
+ );
17363
+ }),
17364
+ ));
17365
+ } else if (!site.length) {
17366
+ children.push(React.createElement("div", { key: "siteEmpty", className: "we-picker__hint", style: { marginTop: "6px" } },
17367
+ weT("站点上还没有桌宠。")));
17368
+ }
17369
+
17370
+ return React.createElement("div", { className: "we-picker__section" }, children);
17371
+ }
17372
+
17373
+ /** 页签组装点调用它。 */
17374
+ function renderPetsTab() {
17375
+ return React.createElement(PetsTabPanel, null);
17376
+ }
17377
+
17378
+ // ── 内联模块:src/pet-layer.js —— 桌宠渲染层:canvas 逐帧绘 sprite2d 雪碧图 + 拖动 + 状态机(自渲染,不依赖第三方宠物插件)(构建期注入;勿手改本段)──
17379
+ /**
17380
+ * pet-layer.js — 桌宠**渲染层**(自己画,不依赖任何第三方宠物插件)。
17381
+ *
17382
+ * 形态:`<body>` 下的一个 fixed 容器 + 一张 canvas,用 sprite2d 雪碧图逐帧绘制。
17383
+ *
17384
+ * 契约(与宿主 `/folia/pets/list` 一致):
17385
+ * pets[i] = { id, dirId, name, renderer, cell:{width,height}, columns, frames:number[9],
17386
+ * tracks:{ <轨道名>: { durations:number[] } }, sheet:'/folia/pets/sheet?v=<dirId>' }
17387
+ *
17388
+ * 行序(9 行,dsh-pet 的 contract,缺轨道时回落 idle):
17389
+ * 0 idle · 1 右跑 · 2 左跑 · 3 挥手 · 4 跳 · 5 失败 · 6 等待 · 7 工作 · 8 完成
17390
+ *
17391
+ * 为什么整层是"命令式 DOM + rAF"而不是 React:它每秒重绘几十次,进 React 只会让
17392
+ * 每次 setState 触发整棵树协调;这里只碰一个 canvas 的像素,与面板渲染互不干扰。
17393
+ */
17394
+
17395
+ const PET_LAYER_ID = "we-pet-layer";
17396
+ const PET_ROW = { idle: 0, runRight: 1, runLeft: 2, wave: 3, jump: 4, fail: 5, wait: 6, work: 7, done: 8 };
17397
+ const PET_DEFAULT_FRAME_MS = 120;
17398
+ /** 光标静止后多久切回 idle(毫秒)。 */
17399
+ const PET_WAVE_IDLE_MS = 9000;
17400
+
17401
+ /**
17402
+ * 桌宠实例:一只宠物一条生命周期(换宠物 = 销毁重来)。
17403
+ * 不导出(构建期内联进同一作用域),只由 mountPetLayer 使用。
17404
+ */
17405
+ function createPetLayer(layerEl, canvas, pet, scale) {
17406
+ const ctx = canvas.getContext("2d");
17407
+ const img = new Image();
17408
+ let frame = 0;
17409
+ let row = PET_ROW.idle;
17410
+ let acc = 0;
17411
+ let last = 0;
17412
+ let raf = 0;
17413
+ let dead = false;
17414
+ let idleSince = 0;
17415
+ let dragging = null;
17416
+ let ready = false;
17417
+
17418
+ const cw = Math.max(1, Number(pet.cell && pet.cell.width) || 192);
17419
+ const ch = Math.max(1, Number(pet.cell && pet.cell.height) || 208);
17420
+ const cols = Math.max(1, Number(pet.columns) || 8);
17421
+ const frames = Array.isArray(pet.frames) && pet.frames.length ? pet.frames : new Array(9).fill(cols);
17422
+
17423
+ function rowFrames(r) {
17424
+ const n = Number(frames[r]);
17425
+ return Number.isFinite(n) && n > 0 ? Math.min(cols, n) : cols;
17426
+ }
17427
+ function frameMs(r, f) {
17428
+ const t = pet.tracks && pet.tracks[Object.keys(PET_ROW)[r] === undefined ? "" : Object.keys(PET_ROW)[r]];
17429
+ const d = t && Array.isArray(t.durations) ? t.durations[f] : null;
17430
+ return Number.isFinite(Number(d)) && Number(d) > 0 ? Number(d) : PET_DEFAULT_FRAME_MS;
17431
+ }
17432
+
17433
+ function resize() {
17434
+ const w = Math.round(cw * scale);
17435
+ const h = Math.round(ch * scale);
17436
+ const dpr = Math.min(3, Math.max(1, (typeof window !== "undefined" && window.devicePixelRatio) || 1));
17437
+ canvas.width = Math.round(w * dpr);
17438
+ canvas.height = Math.round(h * dpr);
17439
+ canvas.style.width = w + "px";
17440
+ canvas.style.height = h + "px";
17441
+ if (canvas.getContext) {
17442
+ const c2 = canvas.getContext("2d");
17443
+ if (c2 && c2.setTransform) c2.setTransform(dpr, 0, 0, dpr, 0, 0);
17444
+ }
17445
+ }
17446
+
17447
+ function draw() {
17448
+ if (!ready || dead) return;
17449
+ const n = rowFrames(row);
17450
+ if (frame >= n) frame = 0;
17451
+ const sx = frame * cw;
17452
+ const sy = row * ch;
17453
+ try {
17454
+ ctx.clearRect(0, 0, cw * scale, ch * scale);
17455
+ ctx.drawImage(img, sx, sy, cw, ch, 0, 0, cw * scale, ch * scale);
17456
+ } catch { /* 贴图还没解码完:下一帧再画 */ }
17457
+ }
17458
+
17459
+ function tick(now) {
17460
+ if (dead) return;
17461
+ if (!last) last = now;
17462
+ acc += now - last;
17463
+ last = now;
17464
+ const ms = frameMs(row, frame);
17465
+ if (acc >= ms) {
17466
+ acc = 0;
17467
+ frame += 1;
17468
+ if (frame >= rowFrames(row)) {
17469
+ frame = 0;
17470
+ // 一次性动作(挥手 / 跳)播完回 idle
17471
+ if (row === PET_ROW.wave || row === PET_ROW.jump) row = PET_ROW.idle;
17472
+ }
17473
+ }
17474
+ draw();
17475
+ raf = (typeof requestAnimationFrame === "function")
17476
+ ? requestAnimationFrame(tick)
17477
+ : setTimeout(() => tick(Date.now()), 60);
17478
+ }
17479
+
17480
+ function setRow(next) {
17481
+ if (row === next) return;
17482
+ row = next;
17483
+ frame = 0;
17484
+ acc = 0;
17485
+ }
17486
+
17487
+ function onPointerDown(e) {
17488
+ if (!layerEl) return;
17489
+ dragging = {
17490
+ dx: e.clientX - layerEl.offsetLeft,
17491
+ dy: e.clientY - layerEl.offsetTop,
17492
+ moved: false,
17493
+ };
17494
+ try { canvas.setPointerCapture && canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ }
17495
+ e.preventDefault();
17496
+ }
17497
+ function onPointerMove(e) {
17498
+ if (!dragging) return;
17499
+ const x = Math.max(0, Math.min((window.innerWidth || 1920) - layerEl.offsetWidth, e.clientX - dragging.dx));
17500
+ const y = Math.max(0, Math.min((window.innerHeight || 1080) - layerEl.offsetHeight, e.clientY - dragging.dy));
17501
+ layerEl.style.left = x + "px";
17502
+ layerEl.style.top = y + "px";
17503
+ layerEl.style.right = "auto";
17504
+ layerEl.style.bottom = "auto";
17505
+ dragging.moved = true;
17506
+ }
17507
+ function onPointerUp() {
17508
+ if (!dragging) return;
17509
+ const moved = dragging.moved;
17510
+ dragging = null;
17511
+ if (!moved) {
17512
+ // 单击 = 挥手(给用户一个"它有反应"的反馈),之后自动回 idle
17513
+ setRow(PET_ROW.wave);
17514
+ } else {
17515
+ try { localStorage.setItem("wePetPos", JSON.stringify({ left: layerEl.style.left, top: layerEl.style.top })); } catch { /* ignore */ }
17516
+ }
17517
+ }
17518
+
17519
+ img.onload = () => { ready = true; resize(); draw(); };
17520
+ img.onerror = () => { destroy(); };
17521
+ img.src = apiUrl(pet.sheet || "/pets/sheet?v=" + encodeURIComponent(pet.dirId || pet.id));
17522
+
17523
+ canvas.addEventListener("pointerdown", onPointerDown);
17524
+ if (typeof window !== "undefined") {
17525
+ window.addEventListener("pointermove", onPointerMove);
17526
+ window.addEventListener("pointerup", onPointerUp);
17527
+ }
17528
+ idleSince = Date.now();
17529
+ raf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(tick) : setTimeout(() => tick(Date.now()), 60);
17530
+
17531
+ function destroy() {
17532
+ dead = true;
17533
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf); else clearTimeout(raf);
17534
+ canvas.removeEventListener("pointerdown", onPointerDown);
17535
+ if (typeof window !== "undefined") {
17536
+ window.removeEventListener("pointermove", onPointerMove);
17537
+ window.removeEventListener("pointerup", onPointerUp);
17538
+ }
17539
+ }
17540
+
17541
+ return { destroy, setRow, resize };
17542
+ }
17543
+
17544
+ /**
17545
+ * 挂载桌宠层。返回卸载函数(无宠物 / 未启用时返回 null)。
17546
+ *
17547
+ * 它自己拉 `/folia/pets/list`(配置 + 已装宠物),所以**不依赖面板是否打开过** ——
17548
+ * 冷启动就能出宠物。
17549
+ */
17550
+ function mountPetLayer() {
17551
+ if (typeof document === "undefined" || !document.body) return null;
17552
+ let disposed = false;
17553
+ let instance = null;
17554
+ let layerEl = null;
17555
+
17556
+ function teardownDom() {
17557
+ if (instance) { try { instance.destroy(); } catch { /* ignore */ } instance = null; }
17558
+ if (layerEl && layerEl.parentNode) layerEl.parentNode.removeChild(layerEl);
17559
+ layerEl = null;
17560
+ }
17561
+
17562
+ async function sync() {
17563
+ let doc = null;
17564
+ try {
17565
+ const resp = await apiJson("/pets/list", { parse: "always" });
17566
+ doc = (resp && resp.data) || null;
17567
+ } catch { doc = null; }
17568
+ if (disposed) return;
17569
+ const cfg = (doc && doc.config) || null;
17570
+ const pets = (doc && doc.pets) || [];
17571
+ const pet = cfg && cfg.petId ? pets.find((p) => p.dirId === cfg.petId || p.id === cfg.petId) : null;
17572
+ if (!cfg || !cfg.enabled || !pet) { teardownDom(); return; }
17573
+
17574
+ const scale = Number(cfg.scale) > 0 ? Number(cfg.scale) : 1;
17575
+ if (layerEl) { layerEl.style.display = ""; if (instance) instance.resize(); return; }
17576
+
17577
+ layerEl = document.createElement("div");
17578
+ layerEl.id = PET_LAYER_ID;
17579
+ layerEl.style.cssText = [
17580
+ "position:fixed", "right:24px", "bottom:16px", "z-index:2147482000",
17581
+ "cursor:grab", "user-select:none", "touch-action:none",
17582
+ "filter:drop-shadow(0 6px 14px rgba(0,0,0,0.35))",
17583
+ ].join(";");
17584
+ try {
17585
+ const saved = JSON.parse(localStorage.getItem("wePetPos") || "null");
17586
+ if (saved && saved.left && saved.top) {
17587
+ layerEl.style.left = saved.left;
17588
+ layerEl.style.top = saved.top;
17589
+ layerEl.style.right = "auto";
17590
+ layerEl.style.bottom = "auto";
17591
+ }
17592
+ } catch { /* 用默认位置 */ }
17593
+ const canvas = document.createElement("canvas");
17594
+ layerEl.appendChild(canvas);
17595
+ document.body.appendChild(layerEl);
17596
+ instance = createPetLayer(layerEl, canvas, pet, scale);
17597
+ }
17598
+
17599
+ sync();
17600
+ // 配置变化(面板里换宠物/开关)不需要重启:轻量轮询即可,代价是一次本地 HTTP。
17601
+ // ⚠️ 定时器要 feature-detect:宿主在 smoke / 单测沙箱里没有 timers,直接调会 ReferenceError,
17602
+ // 而那一抛会把整个 apply 打成失败(实测:smoke 第 1 步就红)。
17603
+ const timer = typeof setInterval === 'function'
17604
+ ? setInterval(() => { if (!disposed) sync(); }, 4000)
17605
+ : null;
17606
+
17607
+ return () => {
17608
+ disposed = true;
17609
+ if (typeof clearInterval === 'function' && timer !== null) clearInterval(timer);
17610
+ teardownDom();
17611
+ };
17612
+ }
17613
+
17183
17614
  // ── 内联模块:src/quick-panel.js —— 快捷播放面板(官方侧栏 tab 与低版本抽屉共用同一份内容):当前壁纸 + 轮播 + 列表快切 + 声音 + 设置入口。列表虚拟滚动(spacer + 可视窗口)(构建期注入;勿手改本段)──
17184
17615
  /**
17185
17616
  * quick-panel.js — 快捷播放面板(侧边栏的**唯一**内容):当前壁纸 + 轮播 + 三档页签
@@ -17355,6 +17786,8 @@ window.__ModuleLoader__.load({
17355
17786
  { id: "wallpaper", get label() { return weT("壁纸"); } },
17356
17787
  { id: "appearance", get label() { return weT("外观"); } },
17357
17788
  { id: "playback", get label() { return weT("播放"); } },
17789
+ // 桌宠是"桌面上多出来的一只东西",与壁纸/开机动画并列,不是它们的子功能。
17790
+ { id: "pets", get label() { return weT("桌宠"); } },
17358
17791
  ];
17359
17792
  // 页签记忆(同 picker-tab / qp-view 口径:仅 UI 状态,localStorage,不进 config.json)。
17360
17793
  const QP_TAB_KEY = "dsh-folia:qp-tab";
@@ -18069,6 +18502,7 @@ window.__ModuleLoader__.load({
18069
18502
  && React.createElement("div", { className: "we-qp__propsview we-qp__propsview--drill" }, renderUserPropsPanel()),
18070
18503
  // 开机动画档:整区就是那个面板(它自己管加载/下载/开关)。
18071
18504
  qpTab === "boot" && React.createElement(BootTabPanel, null),
18505
+ qpTab === "pets" && React.createElement(PetsTabPanel, null),
18072
18506
  !(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && qpTab !== "boot" && (qpTab === "appearance"
18073
18507
  ? qpRenderAppearancePane(sel, {
18074
18508
  fontOpen, onToggleFontSection: toggleFontSection,
@@ -31401,6 +31835,10 @@ window.__ModuleLoader__.load({
31401
31835
  if (ctx.effect && typeof document !== "undefined") {
31402
31836
  ctx.effect(() => installSkinInterop() || undefined);
31403
31837
  }
31838
+ // 3c. 桌宠层:自己拉 /pets/list 决定是否出宠物,随 fiber 注销(不依赖面板打开过)。
31839
+ if (ctx.effect && typeof document !== "undefined") {
31840
+ ctx.effect(() => mountPetLayer() || undefined);
31841
+ }
31404
31842
 
31405
31843
  // 3. Chat-interface rope dock: the draggable pull-cord + glass repo side
31406
31844
  // 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 */ } });
@@ -122,7 +122,7 @@ async function fetchClipBytes(url, chunks = 4) {
122
122
  * MP4/MOV ISO BMFF —— 偏移 4..8 是 `ftyp`
123
123
  * WebM EBML —— 开头 4 字节是 1A 45 DF A3
124
124
  */
125
- export function sniffVideo(buf) {
125
+ function sniffVideo(buf) {
126
126
  if (!buf || buf.length < 1024) return null;
127
127
  if (buf.length >= 12 && buf.toString('latin1', 4, 8) === 'ftyp') {
128
128
  const brand = buf.toString('latin1', 8, 12);
@@ -134,6 +134,7 @@ export function sniffVideo(buf) {
134
134
  return null;
135
135
  }
136
136
 
137
+ export { sniffVideo };
137
138
  /** 读请求体里的 `{ id }`。 */
138
139
  async function readIdBody(req) {
139
140
  try {
@@ -237,6 +238,10 @@ async function fetchCandidatesUncached() {
237
238
  }
238
239
  }
239
240
 
241
+ /** 注入契约(ADR-0013 D12):本族从 `c` 消费的**必需**键。
242
+ * 判据 test/verify-inject-contracts.mjs 会把它与调用点逐键对账 —— 少一个就红,
243
+ * 所以新增依赖(哪怕只是 `log`)也必须同步写在这里。 */
244
+ export const DEPS = ['disposers','base','log'];
240
245
  export function registerBootRoutes(webServer, c) {
241
246
  const { disposers, base: BASE, log } = c;
242
247
 
@@ -232,8 +232,10 @@ function foliaTypeOfEntry(it) {
232
232
 
233
233
  /** 条目的分类 slug(没有就返回空串)。 */
234
234
  function foliaCategoryOf(it) {
235
- const c = it && it.category;
236
- return String((c && c.slug) || '').trim().toLowerCase();
235
+ // ⚠️ 形参**不要**叫 c:test/verify-inject-contracts.mjs 会把 c.<键> 读成"从注入面消费了
236
+ // 该键",这里一个分类对象曾经让对账表凭空多出 slug。名字取得像注入面就会被当成注入面。
237
+ const category = it && it.category;
238
+ return String((category && category.slug) || '').trim().toLowerCase();
237
239
  }
238
240
 
239
241
  /**
@@ -465,10 +467,11 @@ async function foliaRefresh(force = false) {
465
467
 
466
468
  // 启动预热:插件一装好就在后台抓一次站点清单,这样**重启后的第一次打开面板**也不冷。
467
469
  // 有意不 await、失败静默 —— 预热只是优化,绝不能影响启动。
468
- export function warmInventorySiteCache() {
470
+ function warmInventorySiteCache() {
469
471
  try { foliaRemote(true).catch(() => {}); } catch { /* ignore */ }
470
472
  }
471
473
 
474
+ export { warmInventorySiteCache };
472
475
  export function registerInventoryRoutes(webServer, c) {
473
476
  const { disposers, base: BASE, mediaOriginApi, buildInventory, componentsProbe } = c;
474
477
 
@@ -170,10 +170,15 @@ export async function resolveModules(force = false) {
170
170
 
171
171
  // 启动预热:模块索引很小(~1.6KB),但第一跳要走站点代理;提前拉一次,
172
172
  // 开机动画页签第一次打开就不用等这一跳。
173
- export function warmModulesCache() {
173
+ function warmModulesCache() {
174
174
  try { resolveModules(true).catch(() => {}); } catch { /* ignore */ }
175
175
  }
176
176
 
177
+ /** 注入契约(ADR-0013 D12):本族从 `c` 消费的**必需**键。
178
+ * 判据 test/verify-inject-contracts.mjs 会把它与调用点逐键对账 —— 少一个就红,
179
+ * 所以新增依赖(哪怕只是 `log`)也必须同步写在这里。 */
180
+ export const DEPS = ['disposers','base'];
181
+ export { warmModulesCache };
177
182
  export function registerModulesRoutes(webServer, c) {
178
183
  const { disposers, base: BASE } = c;
179
184
 
@@ -0,0 +1,398 @@
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 const DEPS = ['disposers', 'base', 'log'];
30
+
31
+ const DEFAULT_COLUMNS = 8;
32
+ const DEFAULT_ROWS = 9;
33
+ const DEFAULT_CELL = { width: 192, height: 208 };
34
+ const SHEET_MAX_BYTES = 24 * 1024 * 1024;
35
+ const FETCH_TIMEOUT_MS = 30000;
36
+
37
+ function dataRoot() {
38
+ const base = process.env.DSH_FOLIA_HOME || join(homedir(), '.dsh-folia');
39
+ return join(base, 'pets');
40
+ }
41
+
42
+ function siteBase() {
43
+ const raw = process.env.DSH_FOLIA_SITE;
44
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
45
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : 'https://folia-5ve.pages.dev';
46
+ return base.replace(/\/+$/, '');
47
+ }
48
+
49
+ /** 站点上的桌宠分类 slug(站点 `/api/public/categories` 里就有这一条)。 */
50
+ const PET_CATEGORY = 'desktop-pet';
51
+
52
+ function ensureDir(p) {
53
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
54
+ return p;
55
+ }
56
+
57
+ /**
58
+ * 一只已安装宠物的完整描述;`null` 表示这目录不是合法宠物(必须是**整份**:
59
+ * pet.json + 雪碧图都存在)。宁可少认一只,也不要在前端画出空白方块。
60
+ */
61
+ function readPet(dir, id) {
62
+ const manifestPath = join(dir, 'pet.json');
63
+ let manifest;
64
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return null; }
65
+ if (!manifest || typeof manifest !== 'object') return null;
66
+ const g = (manifest.sprite2d && typeof manifest.sprite2d === 'object') ? manifest.sprite2d : {};
67
+ const sheetName = typeof g.spritesheetPath === 'string' ? basename(g.spritesheetPath) : 'spritesheet.webp';
68
+ const sheetPath = join(dir, sheetName);
69
+ if (!existsSync(sheetPath)) return null;
70
+ const cell = (g.cell && Number(g.cell.width) > 0 && Number(g.cell.height) > 0)
71
+ ? { width: Number(g.cell.width), height: Number(g.cell.height) } : { ...DEFAULT_CELL };
72
+ const columns = Number(g.columns) > 0 ? Number(g.columns) : DEFAULT_COLUMNS;
73
+ const frames = Array.isArray(g.frames) && g.frames.length === DEFAULT_ROWS
74
+ ? g.frames.map((n) => Math.max(1, Math.min(columns, Number(n) || columns)))
75
+ : new Array(DEFAULT_ROWS).fill(columns);
76
+ // 轨道:只保留显式给出且长度合理的 durations;其余由前端用默认节奏。
77
+ const tracks = {};
78
+ if (g.tracks && typeof g.tracks === 'object') {
79
+ for (const [k, v] of Object.entries(g.tracks)) {
80
+ if (v && Array.isArray(v.durations) && v.durations.length) {
81
+ tracks[k] = { durations: v.durations.map((d) => Math.max(16, Number(d) || 120)) };
82
+ }
83
+ }
84
+ }
85
+ return {
86
+ id: String(manifest.id || id),
87
+ dirId: id,
88
+ name: String(manifest.displayName || manifest.id || id),
89
+ author: manifest.author ? String(manifest.author) : null,
90
+ license: manifest.license ? String(manifest.license) : null,
91
+ renderer: String(manifest.renderer || 'sprite2d'),
92
+ bytes: (() => { try { return statSync(sheetPath).size; } catch { return null; } })(),
93
+ cell, columns, frames, tracks,
94
+ sheet: '/folia/pets/sheet?v=' + encodeURIComponent(id),
95
+ };
96
+ }
97
+
98
+ /** 列出本地已安装的宠物(目录扫描;坏目录静默跳过)。 */
99
+ function listInstalled() {
100
+ const root = dataRoot();
101
+ const out = [];
102
+ let names = [];
103
+ try { names = readdirSync(root); } catch { return out; }
104
+ for (const name of names) {
105
+ const dir = join(root, name);
106
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
107
+ const pet = readPet(dir, name);
108
+ if (pet) out.push(pet);
109
+ }
110
+ out.sort((a, b) => a.id.localeCompare(b.id));
111
+ return out;
112
+ }
113
+
114
+ /** 桌面宠物的配置(我们自己的文件,不写别人的配置)。 */
115
+ function configPath() { return join(dirname(dataRoot()), 'pet-config.json'); }
116
+ function readConfig() {
117
+ try {
118
+ const raw = JSON.parse(readFileSync(configPath(), 'utf8'));
119
+ return {
120
+ enabled: raw.enabled === true,
121
+ petId: typeof raw.petId === 'string' ? raw.petId : '',
122
+ scale: Number.isFinite(Number(raw.scale)) ? Math.min(3, Math.max(0.5, Number(raw.scale))) : 1,
123
+ };
124
+ } catch {
125
+ return { enabled: false, petId: '', scale: 1 };
126
+ }
127
+ }
128
+ function writeConfig(next) {
129
+ const cur = readConfig();
130
+ const merged = {
131
+ enabled: next.enabled === undefined ? cur.enabled : Boolean(next.enabled),
132
+ petId: next.petId === undefined ? cur.petId : String(next.petId || ''),
133
+ scale: next.scale === undefined ? cur.scale : Math.min(3, Math.max(0.5, Number(next.scale) || 1)),
134
+ };
135
+ try {
136
+ ensureDir(dirname(configPath()));
137
+ writeFileSync(configPath(), JSON.stringify(merged, null, 2) + '\n');
138
+ } catch { /* 只读盘:本次配置不落盘,但内存里仍按 merged 回给调用方 */ }
139
+ return merged;
140
+ }
141
+
142
+ function sendJson(res, status, body) {
143
+ const text = JSON.stringify(body);
144
+ res.statusCode = status;
145
+ if (typeof res.setHeader === 'function') {
146
+ res.setHeader('content-type', 'application/json; charset=utf-8');
147
+ res.setHeader('cache-control', 'no-store');
148
+ }
149
+ res.end(text);
150
+ }
151
+
152
+ /**
153
+ * 极简 zip 读取器:只做**取条目**这一件事(store / deflate 两种方法)。
154
+ *
155
+ * 为什么不引依赖:宠物包在我们自己手里生成(见 tools/upload-pets.mjs,store 模式),
156
+ * 而站点上别人上传的包也只有这两类方法。整个解析靠中央目录遍历,不猜、不流式、
157
+ * 且对每个条目做**大小与路径安全**检查 —— 一个 zip 解析器最该防的是路径穿越与 zip bomb。
158
+ *
159
+ * @returns {Map<string, Buffer>} 文件名 → 内容(只保留通过检查的条目)
160
+ */
161
+ function unzip(buf, opts = {}) {
162
+ const maxEntry = Number(opts.maxEntry) || 24 * 1024 * 1024;
163
+ const maxTotal = Number(opts.maxTotal) || 64 * 1024 * 1024;
164
+ const out = new Map();
165
+ let total = 0;
166
+ // EOCD 在文件尾 22 字节起,最多带 64KB 注释
167
+ let eocd = -1;
168
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
169
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
170
+ }
171
+ if (eocd < 0) throw new Error('not-a-zip');
172
+ const count = buf.readUInt16LE(eocd + 10);
173
+ let p = buf.readUInt32LE(eocd + 16);
174
+ for (let n = 0; n < count; n++) {
175
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('bad-central');
176
+ const method = buf.readUInt16LE(p + 10);
177
+ const compSize = buf.readUInt32LE(p + 20);
178
+ const nameLen = buf.readUInt16LE(p + 28);
179
+ const extraLen = buf.readUInt16LE(p + 30);
180
+ const commentLen = buf.readUInt16LE(p + 32);
181
+ const localOff = buf.readUInt32LE(p + 42);
182
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
183
+ p += 46 + nameLen + extraLen + commentLen;
184
+ // 路径安全:只收单层文件名,任何目录跳转一律丢弃
185
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) continue;
186
+ if (compSize > maxEntry) continue;
187
+ const lNameLen = buf.readUInt16LE(localOff + 26);
188
+ const lExtraLen = buf.readUInt16LE(localOff + 28);
189
+ const dataAt = localOff + 30 + lNameLen + lExtraLen;
190
+ const comp = buf.subarray(dataAt, dataAt + compSize);
191
+ if (comp.length !== compSize) continue;
192
+ let data;
193
+ try {
194
+ data = method === 0 ? Buffer.from(comp) : method === 8 ? inflateRawSync(comp) : null;
195
+ } catch { data = null; }
196
+ if (!data) continue;
197
+ total += data.length;
198
+ if (total > maxTotal) throw new Error('too-large');
199
+ out.set(name, data);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ function readBodyText(req, maxBytes = 64 * 1024) {
205
+ return new Promise((resolve) => {
206
+ let size = 0;
207
+ const chunks = [];
208
+ req.on('data', (c) => {
209
+ size += c.length;
210
+ if (size > maxBytes) { resolve(''); req.destroy(); return; }
211
+ chunks.push(c);
212
+ });
213
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
214
+ req.on('error', () => resolve(''));
215
+ });
216
+ }
217
+
218
+ export function registerPetsRoutes(webServer, c) {
219
+ const { disposers, base: BASE, log } = c;
220
+
221
+ // ── 站点桌宠清单(分类 desktop-pet) ──────────────────────────────────────
222
+ let siteCache = { at: 0, items: [], error: null };
223
+ async function fetchSitePets(force) {
224
+ if (!force && siteCache.items.length && Date.now() - siteCache.at < 60000) return siteCache;
225
+ const base = siteBase();
226
+ if (!base) { siteCache = { at: Date.now(), items: [], error: 'site-off' }; return siteCache; }
227
+ try {
228
+ const r = await fetch(base + '/api/public/wallpapers?category=' + PET_CATEGORY + '&limit=100', {
229
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
230
+ });
231
+ if (!r.ok) throw new Error('http-' + r.status);
232
+ const doc = await r.json();
233
+ const list = (doc && doc.data && Array.isArray(doc.data.items)) ? doc.data.items : [];
234
+ const items = list.map((it) => {
235
+ const url = String((it && it.url) || '');
236
+ const abs = /^https?:/i.test(url) ? url : (url ? base + url : '');
237
+ const thumb = String((it && it.thumbUrl) || '');
238
+ return {
239
+ id: String((it && (it.slug || it.id)) || ''),
240
+ title: String((it && it.title) || ''),
241
+ bytes: Number(it && it.bytes) || null,
242
+ url: abs,
243
+ cover: thumb && /\.(jpg|jpeg|png|webp|gif|avif)(\?|#|$)/i.test(thumb)
244
+ ? (/^https?:/i.test(thumb) ? thumb : base + thumb) : null,
245
+ };
246
+ }).filter((x) => x.id && x.url);
247
+ siteCache = { at: Date.now(), items, error: null };
248
+ return siteCache;
249
+ } catch (e) {
250
+ siteCache = { at: Date.now(), items: siteCache.items, error: String((e && e.message) || e) };
251
+ return siteCache;
252
+ }
253
+ }
254
+
255
+ // ── GET /pets/list ────────────────────────────────────────────────────────
256
+ disposers.push(webServer.register({
257
+ kind: 'exact',
258
+ path: `${BASE}/pets/list`,
259
+ handler: async (req, res) => {
260
+ const url = new URL(req.url || '/', 'http://x');
261
+ const force = url.searchParams.get('refresh') === '1';
262
+ const site = await fetchSitePets(force);
263
+ const installed = listInstalled();
264
+ const installedIds = new Set(installed.map((p) => p.dirId));
265
+ sendJson(res, 200, {
266
+ ok: true,
267
+ pets: installed,
268
+ site: site.items.map((it) => ({ ...it, installed: installedIds.has(it.id) })),
269
+ siteError: site.error,
270
+ config: readConfig(),
271
+ });
272
+ },
273
+ }));
274
+
275
+ // ── GET /pets/sheet?v=<dirId>(本地雪碧图) ───────────────────────────────
276
+ disposers.push(webServer.register({
277
+ kind: 'exact',
278
+ path: `${BASE}/pets/sheet`,
279
+ handler: async (req, res) => {
280
+ let id = '';
281
+ try { id = new URL(req.url || '/', 'http://x').searchParams.get('v') || ''; } catch { id = ''; }
282
+ const dir = join(dataRoot(), String(id));
283
+ const pet = id && existsSync(dir) ? readPet(dir, id) : null;
284
+ if (!pet) { res.statusCode = 404; res.end(); return; }
285
+ const manifest = (() => { try { return JSON.parse(readFileSync(join(dir, 'pet.json'), 'utf8')); } catch { return {}; } })();
286
+ const sheetName = basename((manifest.sprite2d && manifest.sprite2d.spritesheetPath) || 'spritesheet.webp');
287
+ const file = join(dir, sheetName);
288
+ let size = 0;
289
+ try { size = statSync(file).size; } catch { /* 下面统一 404 */ }
290
+ if (!size || size > SHEET_MAX_BYTES) { res.statusCode = 404; res.end(); return; }
291
+ const type = extname(file).toLowerCase() === '.png' ? 'image/png'
292
+ : extname(file).toLowerCase() === '.gif' ? 'image/gif' : 'image/webp';
293
+ if (typeof res.setHeader === 'function') {
294
+ res.setHeader('content-type', type);
295
+ res.setHeader('content-length', String(size));
296
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
297
+ }
298
+ res.statusCode = 200;
299
+ createReadStream(file).pipe(res);
300
+ },
301
+ }));
302
+
303
+ // ── POST /pets/config(开关 / 选中 / 缩放) ───────────────────────────────
304
+ disposers.push(webServer.register({
305
+ kind: 'exact',
306
+ path: `${BASE}/pets/config`,
307
+ handler: async (req, res) => {
308
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
309
+ const text = await readBodyText(req);
310
+ let patch = {};
311
+ try { patch = JSON.parse(text || '{}'); } catch { sendJson(res, 400, { ok: false, error: 'bad-json' }); return; }
312
+ if (patch.petId && !existsSync(join(dataRoot(), String(patch.petId)))) {
313
+ sendJson(res, 404, { ok: false, error: 'not-installed' });
314
+ return;
315
+ }
316
+ const cfg = writeConfig(patch);
317
+ sendJson(res, 200, { ok: true, config: cfg });
318
+ },
319
+ }));
320
+
321
+ // ── POST /pets/install(从站点条目安装:下载 zip → 解开 → 校验) ─────────
322
+ disposers.push(webServer.register({
323
+ kind: 'exact',
324
+ path: `${BASE}/pets/install`,
325
+ handler: async (req, res) => {
326
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
327
+ const text = await readBodyText(req);
328
+ let id = '';
329
+ try { id = String((JSON.parse(text || '{}') || {}).id || '').trim(); } catch { id = ''; }
330
+ if (!id || /[\\/]/.test(id) || id.includes('..') || id.length > 120) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
331
+
332
+ const site = await fetchSitePets(false);
333
+ const item = site.items.find((x) => x.id === id);
334
+ if (!item) { sendJson(res, 404, { ok: false, error: 'not-on-site', detail: site.error || null }); return; }
335
+
336
+ let buf;
337
+ try {
338
+ const r = await fetch(item.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
339
+ if (!r.ok) { sendJson(res, 502, { ok: false, error: 'http-' + r.status }); return; }
340
+ buf = Buffer.from(await r.arrayBuffer());
341
+ } catch (e) { sendJson(res, 502, { ok: false, error: 'fetch-failed', detail: String((e && e.message) || e) }); return; }
342
+
343
+ if (buf.length < 64 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
344
+ sendJson(res, 422, { ok: false, error: 'not-a-zip' });
345
+ return;
346
+ }
347
+
348
+ let entries;
349
+ try { entries = unzip(buf); } catch (e) { sendJson(res, 422, { ok: false, error: 'bad-zip', detail: String((e && e.message) || e) }); return; }
350
+ const manifestRaw = entries.get('pet.json');
351
+ if (!manifestRaw) { sendJson(res, 422, { ok: false, error: 'no-manifest' }); return; }
352
+ let manifest;
353
+ try { manifest = JSON.parse(manifestRaw.toString('utf8')); } catch { sendJson(res, 422, { ok: false, error: 'bad-manifest' }); return; }
354
+ const sheetName = basename((manifest.sprite2d && manifest.sprite2d.spritesheetPath) || 'spritesheet.webp');
355
+ const sheet = entries.get(sheetName);
356
+ if (!sheet) { sendJson(res, 422, { ok: false, error: 'no-spritesheet', detail: sheetName }); return; }
357
+
358
+ const dir = join(dataRoot(), id);
359
+ try {
360
+ ensureDir(dir);
361
+ for (const [name, data] of entries) writeFileSync(join(dir, basename(name)), data);
362
+ } catch (e) {
363
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
364
+ sendJson(res, 500, { ok: false, error: 'write-failed', detail: String((e && e.message) || e) });
365
+ return;
366
+ }
367
+
368
+ const pet = readPet(dir, id);
369
+ if (!pet) { sendJson(res, 422, { ok: false, error: 'install-invalid' }); return; }
370
+ // 装完就选中它并开启 —— 用户点「安装」的意图就是「我要用它」。
371
+ const cfg = writeConfig({ enabled: true, petId: id });
372
+ if (typeof log === 'function') log('[pets] installed ' + id + ' (' + buf.length + ' bytes)');
373
+ sendJson(res, 200, { ok: true, id, pet, config: cfg });
374
+ },
375
+ }));
376
+
377
+ // ── POST /pets/remove(只删我们目录下的一只) ─────────────────────────────
378
+ disposers.push(webServer.register({
379
+ kind: 'exact',
380
+ path: `${BASE}/pets/remove`,
381
+ handler: async (req, res) => {
382
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
383
+ const text = await readBodyText(req);
384
+ let id = '';
385
+ try { id = String((JSON.parse(text || '{}') || {}).id || ''); } catch { id = ''; }
386
+ if (!id || /[\\/]/.test(id) || id.includes('..')) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
387
+ const dir = join(dataRoot(), id);
388
+ if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
389
+ try { rmSync(dir, { recursive: true, force: true }); } catch { sendJson(res, 500, { ok: false, error: 'remove-failed' }); return; }
390
+ const cfg = readConfig();
391
+ if (cfg.petId === id) writeConfig({ petId: '' });
392
+ if (typeof log === 'function') log('[pets] removed ' + id);
393
+ sendJson(res, 200, { ok: true, id });
394
+ },
395
+ }));
396
+
397
+ return () => { /* 无长驻资源:路由随 disposers 归还 */ };
398
+ }
@@ -55,6 +55,10 @@ function allowed(url) {
55
55
  }
56
56
  }
57
57
 
58
+ /** 注入契约(ADR-0013 D12):本族从 `c` 消费的**必需**键。
59
+ * 判据 test/verify-inject-contracts.mjs 会把它与调用点逐键对账 —— 少一个就红,
60
+ * 所以新增依赖(哪怕只是 `log`)也必须同步写在这里。 */
61
+ export const DEPS = ['disposers','base','ensureImagePreview','log'];
58
62
  export function registerSiteThumbRoutes(webServer, c) {
59
63
  const { disposers, base: BASE, ensureImagePreview, log } = c;
60
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.0.7",
3
+ "version": "4.2.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {