dsh-folia 4.2.0 → 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
@@ -17270,7 +17270,7 @@ window.__ModuleLoader__.load({
17270
17270
  ));
17271
17271
 
17272
17272
  children.push(React.createElement("div", { key: "hint", className: "we-picker__hint", style: { margin: "6px 0 10px" } },
17273
- weT("桌宠由本插件自己绘制(雪碧图逐帧),不依赖其它宠物插件。安装后会立刻出现在右下角,可拖动,点一下会挥手。")));
17273
+ weT("桌宠由本插件自己绘制(雪碧图逐帧),不依赖其它宠物插件。它会跟着模型状态换动作:干活时换成工作行、干完变完成、出错变失败;安装后出现在右下角,可拖动,点一下会挥手并出声。")));
17274
17274
 
17275
17275
  children.push(React.createElement("div", { key: "opts", style: { ...PET_STYLE.row, margin: "2px 0 8px" } },
17276
17276
  React.createElement("label", { style: { display: "flex", gap: "6px", alignItems: "center", fontSize: "12px" } },
@@ -17282,6 +17282,32 @@ window.__ModuleLoader__.load({
17282
17282
  }),
17283
17283
  weT("显示桌宠"),
17284
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("复位位置")),
17285
17311
  React.createElement("label", { style: { display: "flex", gap: "4px", alignItems: "center", fontSize: "12px" } },
17286
17312
  weT("大小"),
17287
17313
  React.createElement("select", {
@@ -17395,14 +17421,113 @@ window.__ModuleLoader__.load({
17395
17421
  const PET_LAYER_ID = "we-pet-layer";
17396
17422
  const PET_ROW = { idle: 0, runRight: 1, runLeft: 2, wave: 3, jump: 4, fail: 5, wait: 6, work: 7, done: 8 };
17397
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;
17398
17438
  /** 光标静止后多久切回 idle(毫秒)。 */
17399
17439
  const PET_WAVE_IDLE_MS = 9000;
17400
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
+
17401
17526
  /**
17402
17527
  * 桌宠实例:一只宠物一条生命周期(换宠物 = 销毁重来)。
17403
17528
  * 不导出(构建期内联进同一作用域),只由 mountPetLayer 使用。
17404
17529
  */
17405
- function createPetLayer(layerEl, canvas, pet, scale) {
17530
+ function createPetLayer(layerEl, canvas, pet, scale, cfg) {
17406
17531
  const ctx = canvas.getContext("2d");
17407
17532
  const img = new Image();
17408
17533
  let frame = 0;
@@ -17414,6 +17539,11 @@ window.__ModuleLoader__.load({
17414
17539
  let idleSince = 0;
17415
17540
  let dragging = null;
17416
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;
17417
17547
 
17418
17548
  const cw = Math.max(1, Number(pet.cell && pet.cell.width) || 192);
17419
17549
  const ch = Math.max(1, Number(pet.cell && pet.cell.height) || 208);
@@ -17461,6 +17591,24 @@ window.__ModuleLoader__.load({
17461
17591
  if (!last) last = now;
17462
17592
  acc += now - last;
17463
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); }
17464
17612
  const ms = frameMs(row, frame);
17465
17613
  if (acc >= ms) {
17466
17614
  acc = 0;
@@ -17469,6 +17617,7 @@ window.__ModuleLoader__.load({
17469
17617
  frame = 0;
17470
17618
  // 一次性动作(挥手 / 跳)播完回 idle
17471
17619
  if (row === PET_ROW.wave || row === PET_ROW.jump) row = PET_ROW.idle;
17620
+ // done / fail 由 holdUntil 计时收尾,不在这里回 idle(否则刚出现的反馈会被吞掉)。
17472
17621
  }
17473
17622
  }
17474
17623
  draw();
@@ -17511,6 +17660,7 @@ window.__ModuleLoader__.load({
17511
17660
  if (!moved) {
17512
17661
  // 单击 = 挥手(给用户一个"它有反应"的反馈),之后自动回 idle
17513
17662
  setRow(PET_ROW.wave);
17663
+ voice.play("wave");
17514
17664
  } else {
17515
17665
  try { localStorage.setItem("wePetPos", JSON.stringify({ left: layerEl.style.left, top: layerEl.style.top })); } catch { /* ignore */ }
17516
17666
  }
@@ -17530,6 +17680,9 @@ window.__ModuleLoader__.load({
17530
17680
 
17531
17681
  function destroy() {
17532
17682
  dead = true;
17683
+ if (typeof clearInterval === "function" && activityTimer !== null) clearInterval(activityTimer);
17684
+ activityTimer = null;
17685
+ try { voice.destroy(); } catch { /* ignore */ }
17533
17686
  if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf); else clearTimeout(raf);
17534
17687
  canvas.removeEventListener("pointerdown", onPointerDown);
17535
17688
  if (typeof window !== "undefined") {
@@ -17538,7 +17691,13 @@ window.__ModuleLoader__.load({
17538
17691
  }
17539
17692
  }
17540
17693
 
17541
- return { destroy, setRow, resize };
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
+ };
17542
17701
  }
17543
17702
 
17544
17703
  /**
@@ -17593,7 +17752,7 @@ window.__ModuleLoader__.load({
17593
17752
  const canvas = document.createElement("canvas");
17594
17753
  layerEl.appendChild(canvas);
17595
17754
  document.body.appendChild(layerEl);
17596
- instance = createPetLayer(layerEl, canvas, pet, scale);
17755
+ instance = createPetLayer(layerEl, canvas, pet, scale, cfg);
17597
17756
  }
17598
17757
 
17599
17758
  sync();
@@ -26,6 +26,7 @@ import { homedir } from 'node:os';
26
26
  import { basename, dirname, extname, join } from 'node:path';
27
27
 
28
28
  /** 注入契约(ADR-0013 D12)。 */
29
+ export { unzip };
29
30
  export const DEPS = ['disposers', 'base', 'log'];
30
31
 
31
32
  const DEFAULT_COLUMNS = 8;
@@ -92,6 +93,23 @@ function readPet(dir, id) {
92
93
  bytes: (() => { try { return statSync(sheetPath).size; } catch { return null; } })(),
93
94
  cell, columns, frames, tracks,
94
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
+ })(),
95
113
  };
96
114
  }
97
115
 
@@ -120,9 +138,11 @@ function readConfig() {
120
138
  enabled: raw.enabled === true,
121
139
  petId: typeof raw.petId === 'string' ? raw.petId : '',
122
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,
123
143
  };
124
144
  } catch {
125
- return { enabled: false, petId: '', scale: 1 };
145
+ return { enabled: false, petId: '', scale: 1, sound: false, volume: 0.6 };
126
146
  }
127
147
  }
128
148
  function writeConfig(next) {
@@ -131,6 +151,8 @@ function writeConfig(next) {
131
151
  enabled: next.enabled === undefined ? cur.enabled : Boolean(next.enabled),
132
152
  petId: next.petId === undefined ? cur.petId : String(next.petId || ''),
133
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)),
134
156
  };
135
157
  try {
136
158
  ensureDir(dirname(configPath()));
@@ -181,8 +203,12 @@ function unzip(buf, opts = {}) {
181
203
  const localOff = buf.readUInt32LE(p + 42);
182
204
  const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
183
205
  p += 46 + nameLen + extraLen + commentLen;
184
- // 路径安全:只收单层文件名,任何目录跳转一律丢弃
185
- if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) continue;
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;
186
212
  if (compSize > maxEntry) continue;
187
213
  const lNameLen = buf.readUInt16LE(localOff + 26);
188
214
  const lExtraLen = buf.readUInt16LE(localOff + 28);
@@ -300,6 +326,35 @@ export function registerPetsRoutes(webServer, c) {
300
326
  },
301
327
  }));
302
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
+
303
358
  // ── POST /pets/config(开关 / 选中 / 缩放) ───────────────────────────────
304
359
  disposers.push(webServer.register({
305
360
  kind: 'exact',
@@ -358,7 +413,13 @@ export function registerPetsRoutes(webServer, c) {
358
413
  const dir = join(dataRoot(), id);
359
414
  try {
360
415
  ensureDir(dir);
361
- for (const [name, data] of entries) writeFileSync(join(dir, basename(name)), data);
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
+ }
362
423
  } catch (e) {
363
424
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
364
425
  sendJson(res, 500, { ok: false, error: 'write-failed', detail: String((e && e.message) || e) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.2.0",
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",