dsh-folia 4.3.1 → 4.5.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
@@ -17212,12 +17212,21 @@ window.__ModuleLoader__.load({
17212
17212
  const [state, setState] = React.useState(null);
17213
17213
  const [busy, setBusy] = React.useState("");
17214
17214
  const [note, setNote] = React.useState(null);
17215
+ const [importable, setImportable] = React.useState([]);
17215
17216
 
17216
17217
  const load = React.useCallback(async (force) => {
17217
17218
  try {
17218
17219
  const resp = await apiJson("/pets/list" + (force ? "?refresh=1" : ""), { parse: "always" });
17219
17220
  const doc = (resp && resp.data) || null;
17220
- if (resp && resp.ok && doc && doc.ok) setState(doc);
17221
+ if (resp && resp.ok && doc && doc.ok) {
17222
+ setState(doc);
17223
+ // 顺带问一下本机其它宠物插件里有哪些可导入的(失败不影响主列表)。
17224
+ try {
17225
+ const im = await apiJson("/pets/importable", { parse: "always" });
17226
+ const imDoc = (im && im.data) || null;
17227
+ if (imDoc && imDoc.ok) setImportable(imDoc.pets || []);
17228
+ } catch { /* 无外部目录是正常情况 */ }
17229
+ }
17221
17230
  else setNote({ ok: false, text: weT("读取失败:") + ((resp && resp.error) || "unknown") });
17222
17231
  } catch (e) {
17223
17232
  setNote({ ok: false, text: weT("请求失败:") + ((e && e.message) || weT("网络错误")) });
@@ -17235,6 +17244,7 @@ window.__ModuleLoader__.load({
17235
17244
  if (resp && resp.ok && r && r.ok) {
17236
17245
  if (kind === "install") setNote({ ok: true, text: weT("已安装并启用:") + (label || "") });
17237
17246
  if (kind === "remove") setNote({ ok: true, text: weT("已卸载:") + (label || "") });
17247
+ if (kind === "import") setNote({ ok: true, text: weT("已导入并启用:") + (label || "") });
17238
17248
  if (kind === "config") setNote({ ok: true, text: weT("已保存") });
17239
17249
  await load(true);
17240
17250
  } else {
@@ -17369,6 +17379,26 @@ window.__ModuleLoader__.load({
17369
17379
  : React.createElement("div", { className: "we-picker__hint" }, weT("还没有安装任何桌宠 —— 从下面的站点清单里挑一只。")),
17370
17380
  ));
17371
17381
 
17382
+ // 本机其它宠物插件里可导入的(dsh-pet 等下载的宠物就落在 $DSH_HOME/pets)
17383
+ if (importable.length) {
17384
+ children.push(React.createElement("div", { key: "impHead", className: "we-picker__section-head", style: { marginTop: "14px" } },
17385
+ React.createElement("span", { className: "we-picker__section-label" },
17386
+ weT("本机可导入") + " · " + importable.length),
17387
+ ));
17388
+ children.push(React.createElement("div", { key: "imp", style: PET_STYLE.grid },
17389
+ importable.map((it) => {
17390
+ const isBusy = busy === "import:" + it.id;
17391
+ return React.createElement("div", { key: "imp:" + it.id, style: PET_STYLE.card },
17392
+ React.createElement("div", { style: PET_STYLE.title }, it.name || it.id),
17393
+ React.createElement("div", { style: PET_STYLE.meta },
17394
+ weT("来自其它宠物插件 · ") + petFmtBytes(it.bytes)),
17395
+ React.createElement("div", { style: PET_STYLE.row },
17396
+ btn(isBusy ? weT("导入中…") : weT("导入"), () => act("import", { id: it.id }, it.name || it.id), Boolean(busy))),
17397
+ );
17398
+ }),
17399
+ ));
17400
+ }
17401
+
17372
17402
  // 站点:未装的可安装
17373
17403
  const available = site.filter((s) => !installedIds.has(s.id));
17374
17404
  children.push(React.createElement("div", { key: "siteHead", className: "we-picker__section-head", style: { marginTop: "14px" } },
@@ -17430,6 +17460,25 @@ window.__ModuleLoader__.load({
17430
17460
 
17431
17461
  const PET_LAYER_ID = "we-pet-layer";
17432
17462
  const PET_ROW = { idle: 0, runRight: 1, runLeft: 2, wave: 3, jump: 4, fail: 5, wait: 6, work: 7, done: 8 };
17463
+ /**
17464
+ * 行号 → 轨道名的**候选表**。
17465
+ *
17466
+ * ⚠️ dsh-pet 的 manifest 用的是 kebab 命名(`running-right` / `waving` / `failed` …),
17467
+ * 我最初按驼峰(`runRight` / `wave`)去查,结果**一次都没命中** —— 每帧都退回默认 120ms,
17468
+ * 于是所有动作的节奏都是同一个硬速度,看起来就像"动作不对/没几个动作"。
17469
+ * 这里按「标准名 → 驼峰别名 → 其它常见写法」依次找,命中即用。
17470
+ */
17471
+ const PET_TRACK_ALIASES = [
17472
+ ['idle'],
17473
+ ['running-right', 'runRight', 'right'],
17474
+ ['running-left', 'runLeft', 'left'],
17475
+ ['waving', 'wave'],
17476
+ ['jumping', 'jump'],
17477
+ ['failed', 'fail'],
17478
+ ['waiting', 'wait'],
17479
+ ['running', 'working', 'work'],
17480
+ ['review', 'done', 'completed'],
17481
+ ];
17433
17482
  const PET_DEFAULT_FRAME_MS = 120;
17434
17483
  /**
17435
17484
  * 活动检测:DSH 的客户端 `ctx` 只暴露 effect/get/slots/surface,没有会话事件,
@@ -17476,7 +17525,7 @@ window.__ModuleLoader__.load({
17476
17525
  return petNow();
17477
17526
  }
17478
17527
  /** 两次随机动作之间的间隔范围(毫秒)。 */
17479
- const PET_IDLE_GAP_MS = [5000, 11000];
17528
+ const PET_IDLE_GAP_MS = [3000, 7000];
17480
17529
 
17481
17530
  /**
17482
17531
  * 声音:包内 `sounds/<事件>.ogg|mp3|wav` 优先(manifest.sounds 显式声明),
@@ -17590,7 +17639,10 @@ window.__ModuleLoader__.load({
17590
17639
  let walkSpeed = 0;
17591
17640
  /** 走动剩余时长(毫秒)。 */
17592
17641
  let walkLeft = 0;
17642
+ /** 单击轮换到第几号轨道。 */
17643
+ let clickIdx = 0;
17593
17644
 
17645
+ let scaleCur = Number(scale) > 0 ? Number(scale) : 1;
17594
17646
  const cw = Math.max(1, Number(pet.cell && pet.cell.width) || 192);
17595
17647
  const ch = Math.max(1, Number(pet.cell && pet.cell.height) || 208);
17596
17648
  const cols = Math.max(1, Number(pet.columns) || 8);
@@ -17600,15 +17652,24 @@ window.__ModuleLoader__.load({
17600
17652
  const n = Number(frames[r]);
17601
17653
  return Number.isFinite(n) && n > 0 ? Math.min(cols, n) : cols;
17602
17654
  }
17655
+ /** 该行的轨道配置(按别名表找;找不到返回 null)。 */
17656
+ function trackOf(r) {
17657
+ const names = PET_TRACK_ALIASES[r] || [];
17658
+ for (const n of names) {
17659
+ const tr = pet.tracks && pet.tracks[n];
17660
+ if (tr && Array.isArray(tr.durations) && tr.durations.length) return tr;
17661
+ }
17662
+ return null;
17663
+ }
17603
17664
  function frameMs(r, f) {
17604
- const t = pet.tracks && pet.tracks[Object.keys(PET_ROW)[r] === undefined ? "" : Object.keys(PET_ROW)[r]];
17605
- const d = t && Array.isArray(t.durations) ? t.durations[f] : null;
17665
+ const tr = trackOf(r);
17666
+ const d = tr ? tr.durations[f] : null;
17606
17667
  return Number.isFinite(Number(d)) && Number(d) > 0 ? Number(d) : PET_DEFAULT_FRAME_MS;
17607
17668
  }
17608
17669
 
17609
17670
  function resize() {
17610
- const w = Math.round(cw * scale);
17611
- const h = Math.round(ch * scale);
17671
+ const w = Math.round(cw * scaleCur);
17672
+ const h = Math.round(ch * scaleCur);
17612
17673
  const dpr = Math.min(3, Math.max(1, (typeof window !== "undefined" && window.devicePixelRatio) || 1));
17613
17674
  canvas.width = Math.round(w * dpr);
17614
17675
  canvas.height = Math.round(h * dpr);
@@ -17627,8 +17688,8 @@ window.__ModuleLoader__.load({
17627
17688
  const sx = frame * cw;
17628
17689
  const sy = row * ch;
17629
17690
  try {
17630
- ctx.clearRect(0, 0, cw * scale, ch * scale);
17631
- ctx.drawImage(img, sx, sy, cw, ch, 0, 0, cw * scale, ch * scale);
17691
+ ctx.clearRect(0, 0, cw * scaleCur, ch * scaleCur);
17692
+ ctx.drawImage(img, sx, sy, cw, ch, 0, 0, cw * scaleCur, ch * scaleCur);
17632
17693
  } catch { /* 贴图还没解码完:下一帧再画 */ }
17633
17694
  }
17634
17695
 
@@ -17734,10 +17795,10 @@ window.__ModuleLoader__.load({
17734
17795
  const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
17735
17796
  if ((dir > 0 && p0.left >= maxX - 2) || (dir < 0 && p0.left <= 2)) {
17736
17797
  setRow(dir > 0 ? PET_ROW.runLeft : PET_ROW.runRight);
17737
- walkSpeed = -dir * (0.9 * scale);
17798
+ walkSpeed = -dir * (0.9 * scaleCur);
17738
17799
  } else {
17739
17800
  setRow(dir > 0 ? PET_ROW.runRight : PET_ROW.runLeft);
17740
- walkSpeed = dir * (0.9 * scale);
17801
+ walkSpeed = dir * (0.9 * scaleCur);
17741
17802
  }
17742
17803
  walkLeft = action.ms;
17743
17804
  return;
@@ -17783,14 +17844,101 @@ window.__ModuleLoader__.load({
17783
17844
  // 单击 = 挥手(给用户一个"它有反应"的反馈),之后自动回 idle
17784
17845
  walkSpeed = 0;
17785
17846
  walkLeft = 0;
17786
- setRow(PET_ROW.wave);
17847
+ // 单击 = 演下一个动作(按 9 条轨道轮换),让用户能逐个看到它会什么。
17848
+ clickIdx = (clickIdx + 1) % PET_TRACK_ALIASES.length;
17849
+ const nextRow = clickIdx === 0 ? PET_ROW.idle : clickIdx;
17850
+ setRow(nextRow);
17787
17851
  holdUntil = petNow() + 1400;
17788
- voice.play("wave");
17852
+ voice.play(nextRow === PET_ROW.fail ? "fail" : nextRow === PET_ROW.done ? "done" : "wave");
17789
17853
  } else {
17790
17854
  try { localStorage.setItem("wePetPos", JSON.stringify({ left: layerEl.style.left, top: layerEl.style.top })); } catch { /* ignore */ }
17791
17855
  }
17792
17856
  }
17793
17857
 
17858
+ /* ── 右键菜单:换宠物之外的全部互动都在这里 ────────────────────────────
17859
+ * 为什么自己做:宿主的页签要打开面板才能操作,而"摸它"这件事应该发生在宠物身上。
17860
+ * 菜单条目 = 9 条动作各一条(点一下立刻试演)+ 声音/大小/复位/隐藏。
17861
+ */
17862
+ let menu = null;
17863
+ function closeMenu() {
17864
+ if (menu && menu.parentNode) menu.parentNode.removeChild(menu);
17865
+ menu = null;
17866
+ }
17867
+ function buildMenuItem(label, onClick) {
17868
+ const b = document.createElement("button");
17869
+ b.type = "button";
17870
+ b.textContent = label;
17871
+ b.style.cssText = "display:block;width:100%;padding:6px 10px;background:transparent;border:0;color:inherit;text-align:left;font-size:12px;cursor:pointer;white-space:nowrap";
17872
+ b.addEventListener("mouseenter", () => { b.style.background = "rgba(255,255,255,0.10)"; });
17873
+ b.addEventListener("mouseleave", () => { b.style.background = "transparent"; });
17874
+ b.addEventListener("click", (ev) => { ev.stopPropagation(); onClick(); });
17875
+ return b;
17876
+ }
17877
+ function openMenu(clientX, clientY) {
17878
+ closeMenu();
17879
+ menu = document.createElement("div");
17880
+ menu.style.cssText = [
17881
+ "position:fixed", "z-index:2147482001", "min-width:150px", "padding:6px 0",
17882
+ "border-radius:8px", "font-size:12px", "color:#eee",
17883
+ "background:rgba(28,32,40,0.96)", "border:1px solid rgba(255,255,255,0.16)",
17884
+ "box-shadow:0 10px 30px rgba(0,0,0,0.45)", "backdrop-filter:blur(8px)",
17885
+ ].join(";");
17886
+ const titles = ["闲置", "向右走", "向左走", "挥手", "跳一下", "失败", "发呆", "干活", "完成"];
17887
+ const head = document.createElement("div");
17888
+ head.textContent = "试演动作";
17889
+ head.style.cssText = "padding:4px 10px 6px;opacity:0.55;font-size:11px";
17890
+ menu.appendChild(head);
17891
+ titles.forEach((name, r) => {
17892
+ menu.appendChild(buildMenuItem(name, () => {
17893
+ walkSpeed = 0; walkLeft = 0;
17894
+ setRow(r);
17895
+ holdUntil = petNow() + 1600;
17896
+ voice.play(r === PET_ROW.fail ? "fail" : r === PET_ROW.done ? "done" : "wave");
17897
+ closeMenu();
17898
+ }));
17899
+ });
17900
+ const hr = document.createElement("div");
17901
+ hr.style.cssText = "height:1px;margin:6px 0;background:rgba(255,255,255,0.14)";
17902
+ menu.appendChild(hr);
17903
+ menu.appendChild(buildMenuItem("再大一点", () => { applyScale(scale + 0.2); closeMenu(); }));
17904
+ menu.appendChild(buildMenuItem("再小一点", () => { applyScale(Math.max(0.5, scale - 0.2)); closeMenu(); }));
17905
+ menu.appendChild(buildMenuItem("回右下角", () => {
17906
+ try { localStorage.removeItem("wePetPos"); } catch { /* ignore */ }
17907
+ layerEl.style.left = ""; layerEl.style.top = "";
17908
+ layerEl.style.right = "24px"; layerEl.style.bottom = "16px";
17909
+ closeMenu();
17910
+ }));
17911
+ menu.appendChild(buildMenuItem("不显示它", () => {
17912
+ // 只隐藏本地这一层;真正的开关在面板里(下一次轮询会被尊重)。
17913
+ if (layerEl) layerEl.style.display = "none";
17914
+ closeMenu();
17915
+ }));
17916
+ document.body.appendChild(menu);
17917
+ const w = menu.offsetWidth || 160;
17918
+ const h = menu.offsetHeight || 320;
17919
+ const maxX = (window.innerWidth || 1920) - w - 8;
17920
+ const maxY = (window.innerHeight || 1080) - h - 8;
17921
+ menu.style.left = Math.max(8, Math.min(maxX, clientX)) + "px";
17922
+ menu.style.top = Math.max(8, Math.min(maxY, clientY)) + "px";
17923
+ }
17924
+ function onContextMenu(e) {
17925
+ e.preventDefault();
17926
+ openMenu(e.clientX, e.clientY);
17927
+ }
17928
+ function onDocDown(e) {
17929
+ if (menu && !menu.contains(e.target)) closeMenu();
17930
+ }
17931
+ /** 菜单里的缩放:改本地样式并写回配置(下一次轮询沿用)。 */
17932
+ function applyScale(next) {
17933
+ const v = Math.max(0.5, Math.min(3, Number(next) || 1));
17934
+ try { apiPostJson("/pets/config", { scale: v }, { parse: "always" }); } catch { /* 本地先变 */ }
17935
+ scaleCur = v;
17936
+ resize();
17937
+ draw();
17938
+ }
17939
+
17940
+ canvas.addEventListener("contextmenu", onContextMenu);
17941
+ if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("pointerdown", onDocDown);
17794
17942
  img.onload = () => { ready = true; resize(); draw(); };
17795
17943
  img.onerror = () => { destroy(); };
17796
17944
  img.src = apiUrl(pet.sheet || "/pets/sheet?v=" + encodeURIComponent(pet.dirId || pet.id));
@@ -17810,6 +17958,9 @@ window.__ModuleLoader__.load({
17810
17958
  try { voice.destroy(); } catch { /* ignore */ }
17811
17959
  if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf); else clearTimeout(raf);
17812
17960
  canvas.removeEventListener("pointerdown", onPointerDown);
17961
+ canvas.removeEventListener("contextmenu", onContextMenu);
17962
+ if (typeof document !== "undefined" && document.removeEventListener) document.removeEventListener("pointerdown", onDocDown);
17963
+ closeMenu();
17813
17964
  if (typeof window !== "undefined") {
17814
17965
  window.removeEventListener("pointermove", onPointerMove);
17815
17966
  window.removeEventListener("pointerup", onPointerUp);
@@ -17914,6 +18065,76 @@ window.__ModuleLoader__.load({
17914
18065
 
17915
18066
  // 仅供单测驱动(构建期内联时会剥掉 export,不影响产物)。
17916
18067
 
18068
+ // ── 内联模块:src/features.js —— 客户端功能注册表:页签条 / 整区面板 / 常驻挂载层。装配点(quick-panel、client.js)只查表,不写功能名(构建期注入;勿手改本段)──
18069
+ /**
18070
+ * features.js(客户端)— **功能注册表**:页签声明、面板工厂、挂载钩子。
18071
+ *
18072
+ * 为什么需要它:加一个功能过去要改五处共享文件(client.js 挂载 / quick-panel 的 QP_TABS 与
18073
+ * 渲染点 / settings-schema / build-client 的内联表 / lib/index.js 的路由注册)。前两处是纯
18074
+ * "接线",写错了症状是「页签在、点开空白」或「渲染层根本没挂」。把这些收成一份声明后:
18075
+ *
18076
+ * · 页签条 = WE_FEATURE_TABS(顺序就是用户看到的顺序)
18077
+ * · 整区面板 = WE_FEATURE_PANELS(哪些页签整区交给功能自己的面板)
18078
+ * · 常驻层 = WE_FEATURE_MOUNTS(随插件生命周期挂载/卸载的浮动层,例如桌宠)
18079
+ *
18080
+ * 谁也别再往 quick-panel 里写 `qpTab === "xxx"` —— 那是把功能名焊死进装配点。
18081
+ * 判据:test/verify-features.mjs 会检查 quick-panel 里不再出现具体功能分支。
18082
+ *
18083
+ * ⚠️ 内联顺序:本文件必须排在**它引用的所有符号之后**(panel-tabs-boot / panel-tabs-pets /
18084
+ * pet-layer),且排在 quick-panel.js 与 client.js **之前** —— 那两处在模块顶层读本表。
18085
+ */
18086
+
18087
+ /** 页签条(顺序 = 界面顺序)。label 用 getter:语言切换后能立刻生效。 */
18088
+ const WE_FEATURE_TABS = [
18089
+ // 开机动画与壁纸并列:它们回答的是同一个问题"开机/桌面看到什么",不是壁纸的子功能。
18090
+ { id: "boot", get label() { return weT("开机动画"); } },
18091
+ { id: "wallpaper", get label() { return weT("壁纸"); } },
18092
+ { id: "appearance", get label() { return weT("外观"); } },
18093
+ { id: "playback", get label() { return weT("播放"); } },
18094
+ // 桌宠是"桌面上多出来的一只东西",与壁纸/开机动画并列。
18095
+ { id: "pets", get label() { return weT("桌宠"); } },
18096
+ ];
18097
+
18098
+ /**
18099
+ * 整区面板:这个页签的内容**整个**由功能自己的组件渲染(而不是 quick-panel 内的通用列表)。
18100
+ * 没登记的页签走 quick-panel 自己的通用布局(壁纸列表 / 外观节 / 播放节)。
18101
+ */
18102
+ const WE_FEATURE_PANELS = {
18103
+ boot: BootTabPanel,
18104
+ pets: PetsTabPanel,
18105
+ };
18106
+
18107
+ /**
18108
+ * 常驻层:插件挂载时启动、卸载时归还。每个都必须返回 cleanup(或 undefined)。
18109
+ * 它们自己决定"要不要显示"(例如桌宠要读配置才知道是否启用),装配点不参与判断。
18110
+ */
18111
+ const WE_FEATURE_MOUNTS = [
18112
+ { id: "pets", mount: mountPetLayer },
18113
+ ];
18114
+
18115
+ /** 页签 → 功能归属(宿主清单 lib/features.js 的客户端镜像,供守卫对账)。 */
18116
+ const WE_FEATURE_TAB_IDS = WE_FEATURE_TABS.map((t) => t.id);
18117
+
18118
+ /**
18119
+ * 页签底部那枚「去设置里改」的引导条。
18120
+ *
18121
+ * 为什么要收进注册表:它原本是 quick-panel 里的一条 `qpTab === "boot"` 分支 —— 属于
18122
+ * 「功能名焊死进装配点」。功能自己声明自己的引导语,装配点只查表。
18123
+ * 没声明的页签走 quick-panel 的通用文案(壁纸 / 外观 / 播放那三档是泛型布局,留在原地)。
18124
+ */
18125
+ const WE_FEATURE_FOOTERS = {
18126
+ boot: {
18127
+ get label() { return weT("打开设置 ›"); },
18128
+ target: "",
18129
+ get title() { return weT("开机动画的全部参数都在侧栏「壁纸引擎」设置里"); },
18130
+ },
18131
+ pets: {
18132
+ get label() { return weT("打开设置 ›"); },
18133
+ target: "",
18134
+ get title() { return weT("桌宠的开关 / 大小 / 声音都在「桌宠」页签里调整"); },
18135
+ },
18136
+ };
18137
+
17917
18138
  // ── 内联模块:src/quick-panel.js —— 快捷播放面板(官方侧栏 tab 与低版本抽屉共用同一份内容):当前壁纸 + 轮播 + 列表快切 + 声音 + 设置入口。列表虚拟滚动(spacer + 可视窗口)(构建期注入;勿手改本段)──
17918
18139
  /**
17919
18140
  * quick-panel.js — 快捷播放面板(侧边栏的**唯一**内容):当前壁纸 + 轮播 + 三档页签
@@ -18083,15 +18304,9 @@ window.__ModuleLoader__.load({
18083
18304
  // 位置在「轮播」之下:当前壁纸与轮播在三档页签下都显示(调外观 / 播放参数时想换一张
18084
18305
  // 对照很常见)。与设置页的页签表(PICKER_TABS)是两件事 —— 这里只镜像用户点名要的
18085
18306
  // 两页,外加壁纸页。
18086
- const QP_TABS = [
18087
- // 开机动画放在「壁纸」左边:它和壁纸是并列的两种"开机时看到什么",不是壁纸的子功能。
18088
- { id: "boot", get label() { return weT("开机动画"); } },
18089
- { id: "wallpaper", get label() { return weT("壁纸"); } },
18090
- { id: "appearance", get label() { return weT("外观"); } },
18091
- { id: "playback", get label() { return weT("播放"); } },
18092
- // 桌宠是"桌面上多出来的一只东西",与壁纸/开机动画并列,不是它们的子功能。
18093
- { id: "pets", get label() { return weT("桌宠"); } },
18094
- ];
18307
+ // 页签条从**功能注册表**取(src/features.js):这里不再出现具体功能名。
18308
+ // 加功能只该动注册表,不该动装配点 —— 否则又变成"每加一个功能改五处"。
18309
+ const QP_TABS = WE_FEATURE_TABS;
18095
18310
  // 页签记忆(同 picker-tab / qp-view 口径:仅 UI 状态,localStorage,不进 config.json)。
18096
18311
  const QP_TAB_KEY = "dsh-folia:qp-tab";
18097
18312
  /** 读页签偏好;缺失 / 非法回落「壁纸」。 */
@@ -18765,12 +18980,11 @@ window.__ModuleLoader__.load({
18765
18980
  // 设置页同名页签 —— 玻璃详细调节仍只在侧栏(本页,折叠块),设置页那边留玻璃预设、
18766
18981
  // 全量配置与**字体节**(三次修订后字体节两档都画);播放页的出图来源 /
18767
18982
  // 实时帧仍住设置页。
18768
- const foot = qpTab === "boot"
18769
- ? {
18770
- label: weT("打开设置 ›"),
18771
- target: "",
18772
- title: weT("开机动画的全部参数都在侧栏「壁纸引擎」设置里"),
18773
- }
18983
+ // 引导条:整区面板类页签的文案由**功能注册表**给(WE_FEATURE_FOOTERS),装配点只查表;
18984
+ // 外观 / 播放 / 壁纸是泛型布局,文案留在原地。
18985
+ const featFooter = WE_FEATURE_FOOTERS[qpTab] || null;
18986
+ const foot = featFooter
18987
+ ? featFooter
18774
18988
  : qpTab === "appearance"
18775
18989
  ? {
18776
18990
  label: weT("更多外观设置 ›"),
@@ -18803,10 +19017,10 @@ window.__ModuleLoader__.load({
18803
19017
  React.createElement("div", { className: tabBodyClass, ref: qpPaneRef },
18804
19018
  qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable
18805
19019
  && React.createElement("div", { className: "we-qp__propsview we-qp__propsview--drill" }, renderUserPropsPanel()),
18806
- // 开机动画档:整区就是那个面板(它自己管加载/下载/开关)。
18807
- qpTab === "boot" && React.createElement(BootTabPanel, null),
18808
- qpTab === "pets" && React.createElement(PetsTabPanel, null),
18809
- !(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && qpTab !== "boot" && (qpTab === "appearance"
19020
+ // 整区面板:由注册表声明哪些页签整块交给功能自己的组件(开机动画 / 桌宠)。
19021
+ // 装配点只查表,不写功能名 —— 判据见 test/verify-features.mjs。
19022
+ Boolean(WE_FEATURE_PANELS[qpTab]) && React.createElement(WE_FEATURE_PANELS[qpTab], null),
19023
+ !(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && !WE_FEATURE_PANELS[qpTab] && (qpTab === "appearance"
18810
19024
  ? qpRenderAppearancePane(sel, {
18811
19025
  fontOpen, onToggleFontSection: toggleFontSection,
18812
19026
  uiColorOpen, onToggleUiColorSection: toggleUiColorSection,
@@ -32138,9 +32352,13 @@ window.__ModuleLoader__.load({
32138
32352
  if (ctx.effect && typeof document !== "undefined") {
32139
32353
  ctx.effect(() => installSkinInterop() || undefined);
32140
32354
  }
32141
- // 3c. 桌宠层:自己拉 /pets/list 决定是否出宠物,随 fiber 注销(不依赖面板打开过)。
32142
- if (ctx.effect && typeof document !== "undefined") {
32143
- ctx.effect(() => mountPetLayer() || undefined);
32355
+ // 3c. 常驻层:由**功能注册表**声明(src/features.js 的 WE_FEATURE_MOUNTS)。
32356
+ // 装配点不再点名具体功能 —— 每个 mount 自己决定要不要显示(例如桌宠先读配置)。
32357
+ if (typeof WE_FEATURE_MOUNTS !== "undefined") {
32358
+ for (const feat of WE_FEATURE_MOUNTS) {
32359
+ if (!ctx.effect || typeof document === "undefined") break;
32360
+ ctx.effect(() => feat.mount() || undefined);
32361
+ }
32144
32362
  }
32145
32363
 
32146
32364
  // 3. Chat-interface rope dock: the draggable pull-cord + glass repo side
@@ -0,0 +1,260 @@
1
+ /**
2
+ * lib/features.js — **功能清单**(单一真源)。
3
+ *
4
+ * 为什么需要这张表:这个插件已经长到 19 个宿主路由文件 + 70 个客户端文件 + 24k 行,
5
+ * 而"某个功能到底由哪些文件负责"过去只存在于人脑里。后果是真实的:
6
+ * · 加一个功能要同时改 client.js / quick-panel.js / settings-schema.js / build-client.mjs
7
+ * / lib/index.js 五处接线,漏一处就"功能看着在、点开是空的";
8
+ * · 有的功能(如站点缩略图)横跨 host + client,谁都能往里塞逻辑,边界逐渐糊掉。
9
+ *
10
+ * 这张表把"归属"写成数据,并由 test/verify-features.mjs 机器校验:
11
+ * ① 表里每个文件都必须真实存在;
12
+ * ② `lib/routes/*.js` 里的**每一个文件都必须被且仅被一个功能认领**(不许有孤儿路由);
13
+ * ③ 每个 `tab` 必须真的出现在客户端 QP_TABS 里(不许有幽灵页签);
14
+ * ④ 用到的站点分类必须在这里登记(站点是数据源,分类就是功能的入口)。
15
+ *
16
+ * 这张表**不改变运行行为**(运行时仍按各文件自己的注册方式装配),它的价值是:
17
+ * 让"模块化"变成可失败的判据,而不是口头承诺。下次要加功能,先在这里登记,再动手。
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} Feature
22
+ * @property {string} id 功能 id(与 tab / 站点分类对齐)
23
+ * @property {string} name 中文名(界面与文档用)
24
+ * @property {string} role 这一层负责什么(一句话)
25
+ * @property {string[]} host 宿主侧文件(相对仓库根)
26
+ * @property {string[]} client 浏览器侧文件(相对仓库根)
27
+ * @property {string[]} [tab] 客户端页签 id(没有页签的写 null)
28
+ * @property {string[]} [site] 使用的站点分类 slug(站点数据源入口)
29
+ * @property {string[]} [settings] 拥有的设置键前缀(可空)
30
+ */
31
+
32
+ /** @type {Feature[]} */
33
+ export const FEATURES = [
34
+ {
35
+ id: 'wallpaper',
36
+ name: '壁纸',
37
+ role: '本机/上传/站点三源合并成壁纸库;着色、图层、播放与轮换由客户端接管',
38
+ host: [
39
+ 'lib/routes/inventory.js',
40
+ 'lib/routes/media-bytes.js',
41
+ 'lib/routes/media-derived.js',
42
+ 'lib/routes/scene-frame.js',
43
+ 'lib/routes/scene-media.js',
44
+ 'lib/routes/scene-serve.js',
45
+ 'lib/routes/live-frame.js',
46
+ 'lib/routes/occlusion.js',
47
+ 'lib/routes/props.js',
48
+ 'lib/routes/we-assets.js',
49
+ 'lib/routes/upload.js',
50
+ 'lib/inventory.js',
51
+ 'lib/wallpaper-scan.js',
52
+ 'lib/upload-store.js',
53
+ 'lib/scene-manifest.js',
54
+ 'lib/faststart.js',
55
+ 'lib/mp4-vfs.js',
56
+ 'lib/media/frame-store.js',
57
+ 'lib/media/transcode.js',
58
+ 'lib/media/video-preview.js',
59
+ 'lib/media/image-preview.js',
60
+ 'lib/media/scene-probe.js',
61
+ 'lib/media/mp4-probe.js',
62
+ 'lib/media/media-dirs.js',
63
+ ],
64
+ client: [
65
+ 'src/quick-panel.js',
66
+ 'src/picker-modal.js',
67
+ 'src/picker-model.js',
68
+ 'src/picker-props-panel.js',
69
+ 'src/panel-tabs-wallpaper.js',
70
+ 'src/video-layer.js',
71
+ 'src/layer-core.js',
72
+ 'src/live-layer.js',
73
+ 'src/live-layer-watch.js',
74
+ 'src/live-layer-gate.js',
75
+ 'src/live-layer-pointer.js',
76
+ 'src/live-layer-underlay.js',
77
+ 'src/live-layer-diag.js',
78
+ 'src/media-prep.js',
79
+ 'src/client-media-frame.js',
80
+ 'src/client-rotation.js',
81
+ 'src/client-audio-playback.js',
82
+ 'src/occlusion.js',
83
+ ],
84
+ tab: ['wallpaper'],
85
+ site: ['wallpaper'],
86
+ },
87
+ {
88
+ id: 'site',
89
+ name: '站点数据源',
90
+ role: '把站点当唯一数据源:清单拉取、分类过滤、缩略图代理、模块索引',
91
+ host: [
92
+ 'lib/routes/site-thumb.js',
93
+ 'lib/routes/modules.js',
94
+ ],
95
+ client: [],
96
+ tab: [],
97
+ site: ['wallpaper', 'boot-animation', 'desktop-pet', 'assistant-skin'],
98
+ },
99
+ {
100
+ id: 'boot',
101
+ name: '开机动画',
102
+ role: '站点候选 → 并行下载 → 本地目录 → 启动注入播放(脚本在宿主侧注入,不依赖应用挂载)',
103
+ host: [
104
+ 'lib/routes/boot.js',
105
+ 'lib/boot/index.js',
106
+ ],
107
+ client: ['src/panel-tabs-boot.js'],
108
+ tab: ['boot'],
109
+ site: ['boot-animation'],
110
+ settings: ['boot'],
111
+ },
112
+ {
113
+ id: 'pets',
114
+ name: '桌宠',
115
+ role: '自渲染:站点/本机宠物 → 安装解包 → canvas 逐帧绘 sprite2d + 动作状态机 + 语音',
116
+ host: ['lib/routes/pets.js'],
117
+ client: ['src/panel-tabs-pets.js', 'src/pet-layer.js'],
118
+ tab: ['pets'],
119
+ site: ['desktop-pet'],
120
+ settings: ['pet'],
121
+ },
122
+ {
123
+ id: 'appearance',
124
+ name: '外观',
125
+ role: '玻璃、主题、字体、预设、页签样式 —— 纯客户端表现层',
126
+ host: [
127
+ 'lib/routes/settings.js',
128
+ 'lib/routes/presets.js',
129
+ 'lib/settings-schema.js',
130
+ 'lib/components/fonts/routes/fontsets.js',
131
+ 'lib/components/fonts/routes/system-fonts.js',
132
+ ],
133
+ client: [
134
+ 'src/panel-tabs-appearance.js',
135
+ 'src/glass.js',
136
+ 'src/glass-panel.js',
137
+ 'src/glass-surfaces.js',
138
+ 'src/glass-extra.js',
139
+ 'src/theme-follow.js',
140
+ 'src/system-fonts.js',
141
+ 'src/fontset-store.js',
142
+ 'src/preset-store.js',
143
+ 'src/styles-base.js',
144
+ 'src/styles-chrome.js',
145
+ 'src/styles-ext.js',
146
+ 'src/styles-panel.js',
147
+ 'src/styles-quick.js',
148
+ 'src/styles-settings.js',
149
+ 'src/styles-shell.js',
150
+ 'src/styles-sidebar.js',
151
+ 'src/about-assets.js',
152
+ 'src/nav-icon.js',
153
+ ],
154
+ tab: ['appearance'],
155
+ },
156
+ {
157
+ id: 'playback',
158
+ name: '播放',
159
+ role: '音量、静音、轮换节奏与播放控制(壁纸的播放面,独立页签)',
160
+ host: [],
161
+ client: [
162
+ 'src/panel-tabs-advanced.js',
163
+ 'src/effects.js',
164
+ 'src/adapter.js',
165
+ ],
166
+ tab: ['playback'],
167
+ },
168
+ {
169
+ id: 'components',
170
+ name: '可选组件行',
171
+ role: '上游形态保留的 7 个组件行(fx/parallax/fonts/mascot-avatar/media-bridge/pet-link/about-notice),各自独立装载',
172
+ host: [
173
+ 'lib/components/fx/index.js',
174
+ 'lib/components/parallax/index.js',
175
+ 'lib/components/fonts/index.js',
176
+ 'lib/components/mascot-avatar/index.js',
177
+ 'lib/components/media-bridge/index.js',
178
+ 'lib/components/pet-link/index.js',
179
+ 'lib/components/about-notice/index.js',
180
+ ],
181
+ client: [],
182
+ tab: [],
183
+ },
184
+ {
185
+ id: 'system',
186
+ name: '系统',
187
+ role: '诊断、缓存目录、设置存储、客户端装配骨架(不属于任何单个功能)',
188
+ host: [
189
+ 'lib/routes/diag.js',
190
+ 'lib/routes/cache-dir.js',
191
+ 'lib/index.js',
192
+ 'lib/kernel/host-kernel.js',
193
+ 'lib/kernel/host-session.js',
194
+ 'lib/kernel/config.js',
195
+ 'lib/kernel/data-dir.js',
196
+ 'lib/diag-trace.js',
197
+ 'lib/cache-dir.js',
198
+ 'lib/serve.js',
199
+ 'lib/http-body.js',
200
+ 'lib/http-lifecycle.js',
201
+ 'lib/json-response.js',
202
+ 'lib/pkg-read.js',
203
+ 'lib/log.js',
204
+ 'lib/notice.js',
205
+ 'lib/host-boot.js',
206
+ 'lib/subpackage-boot.js',
207
+ 'lib/startup-sweep.js',
208
+ 'lib/fs-paths.js',
209
+ 'lib/upload-dir.js',
210
+ 'lib/steam-discovery.js',
211
+ 'lib/shim-seed.js',
212
+ 'lib/we-props.js',
213
+ 'lib/we-focus-guard.js',
214
+ 'lib/media-origin.js',
215
+ 'lib/types/index.d.ts',
216
+ 'lib/types/client.d.ts',
217
+ ],
218
+ client: [
219
+ 'src/client.js',
220
+ 'src/api-client.js',
221
+ 'src/persistence.js',
222
+ 'src/extension-registry.js',
223
+ 'src/i18n.js',
224
+ 'src/i18n-copy.js',
225
+ 'src/i18n-copy-core.js',
226
+ 'src/i18n-copy-ext.js',
227
+ 'src/i18n-copy-font.js',
228
+ 'src/i18n-copy-glass.js',
229
+ 'src/i18n-copy-live.js',
230
+ 'src/i18n-copy-panel.js',
231
+ 'src/i18n-copy-pet-link.js',
232
+ 'src/i18n-copy-picker.js',
233
+ 'src/we-base.js',
234
+ 'src/we-cond.js',
235
+ 'src/module-reset.js',
236
+ 'src/focus-handback.js',
237
+ 'src/sidebar-right.js',
238
+ 'src/section-head.js',
239
+ 'src/avatar-import.js',
240
+ 'src/avatar-layer.js',
241
+ 'src/ext-avatar.js',
242
+ 'src/panel-tabs.js',
243
+ 'src/panel-tabs-effects.js',
244
+ 'src/panel-tabs-mascot.js',
245
+ ],
246
+ tab: [],
247
+ },
248
+ ];
249
+
250
+ /** 页签 id → 功能 id(供校验与文档使用)。 */
251
+ export const TAB_OWNERS = FEATURES.reduce((acc, f) => {
252
+ for (const t of f.tab || []) acc[t] = f.id;
253
+ return acc;
254
+ }, {});
255
+
256
+ /** 站点分类 slug → 功能 id。 */
257
+ export const SITE_OWNERS = FEATURES.reduce((acc, f) => {
258
+ for (const s of f.site || []) acc[s] = f.id;
259
+ return acc;
260
+ }, {});
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { createHash } from 'node:crypto';
23
23
  import { inflateRawSync } from 'node:zlib';
24
- import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
+ import { cpSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { basename, dirname, extname, join } from 'node:path';
27
27
 
@@ -50,6 +50,35 @@ function siteBase() {
50
50
  /** 站点上的桌宠分类 slug(站点 `/api/public/categories` 里就有这一条)。 */
51
51
  const PET_CATEGORY = 'desktop-pet';
52
52
 
53
+ /**
54
+ * 别的宠物插件把用户下载的宠物放在 `$DSH_HOME/pets/<id>/`(dsh-pet 的公开契约)。
55
+ *
56
+ * 为什么提供"导入"而不是"共用目录":共用会让两边的生命周期纠缠在一起(对方删一只、
57
+ * 我们这边跟着消失)。**复制一份进我们自己的目录**,两边互不影响,代价只是多占几 MB。
58
+ */
59
+ function externalPetsDir() {
60
+ const home = process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
61
+ return join(home, 'pets');
62
+ }
63
+
64
+ /** 本机其它宠物插件目录里、我们还没装的那些(只读扫描,不写对方目录)。 */
65
+ function listImportable() {
66
+ const src = externalPetsDir();
67
+ const out = [];
68
+ let names = [];
69
+ try { names = readdirSync(src); } catch { return out; }
70
+ const mine = new Set(listInstalled().map((p) => p.dirId));
71
+ for (const name of names) {
72
+ if (mine.has(name)) continue;
73
+ const dir = join(src, name);
74
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
75
+ const pet = readPet(dir, name);
76
+ if (!pet) continue;
77
+ out.push({ id: name, name: pet.name, bytes: pet.bytes, columns: pet.columns, cell: pet.cell });
78
+ }
79
+ return out;
80
+ }
81
+
53
82
  function ensureDir(p) {
54
83
  if (!existsSync(p)) mkdirSync(p, { recursive: true });
55
84
  return p;
@@ -355,6 +384,43 @@ export function registerPetsRoutes(webServer, c) {
355
384
  },
356
385
  }));
357
386
 
387
+ // ── GET /pets/importable(本机其它宠物插件里可导入的) ────────────────────
388
+ disposers.push(webServer.register({
389
+ kind: 'exact',
390
+ path: `${BASE}/pets/importable`,
391
+ handler: async (req, res) => {
392
+ let pets = [];
393
+ try { pets = listImportable(); } catch (e) { sendJson(res, 200, { ok: true, pets: [], error: String((e && e.message) || e) }); return; }
394
+ sendJson(res, 200, { ok: true, pets, dir: externalPetsDir() });
395
+ },
396
+ }));
397
+
398
+ // ── POST /pets/import(把本机另一只宠物复制进来) ─────────────────────────
399
+ disposers.push(webServer.register({
400
+ kind: 'exact',
401
+ path: `${BASE}/pets/import`,
402
+ handler: async (req, res) => {
403
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
404
+ const text = await readBodyText(req);
405
+ let id = '';
406
+ try { id = String((JSON.parse(text || '{}') || {}).id || '').trim(); } catch { id = ''; }
407
+ if (!id || /[\\/]/.test(id) || id.includes('..') || id.length > 120) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
408
+ const src = join(externalPetsDir(), id);
409
+ if (!existsSync(src)) { sendJson(res, 404, { ok: false, error: 'not-found' }); return; }
410
+ if (!readPet(src, id)) { sendJson(res, 422, { ok: false, error: 'invalid-pet' }); return; }
411
+ const dst = join(dataRoot(), id);
412
+ try {
413
+ ensureDir(dirname(dst));
414
+ cpSync(src, dst, { recursive: true, force: true });
415
+ } catch (e) { sendJson(res, 500, { ok: false, error: 'copy-failed', detail: String((e && e.message) || e) }); return; }
416
+ const pet = readPet(dst, id);
417
+ if (!pet) { sendJson(res, 422, { ok: false, error: 'install-invalid' }); return; }
418
+ const cfg = writeConfig({ enabled: true, petId: id });
419
+ if (typeof log === 'function') log('[pets] imported ' + id + ' from ' + externalPetsDir());
420
+ sendJson(res, 200, { ok: true, id, pet, config: cfg });
421
+ },
422
+ }));
423
+
358
424
  // ── POST /pets/config(开关 / 选中 / 缩放) ───────────────────────────────
359
425
  disposers.push(webServer.register({
360
426
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.3.1",
3
+ "version": "4.5.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -155,7 +155,8 @@
155
155
  "README.md",
156
156
  "README.en.md",
157
157
  "README.beginner.md",
158
- "lib/components/"
158
+ "lib/components/",
159
+ "lib/features.js"
159
160
  ],
160
161
  "dsh": {
161
162
  "bundle": {
@@ -193,7 +194,8 @@
193
194
  "test:boot": "node test/boot-http.mjs",
194
195
  "test:pets": "node test/pets-http.mjs",
195
196
  "test:pets-layer": "node test/pets-layer.mjs",
196
- "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 \u0026\u0026 node test/pets-layer.mjs"
197
+ "test:features": "node test/verify-features.mjs",
198
+ "gate": "node scripts/build-client.mjs \u0026\u0026 node test/run-guards.mjs smoke \u0026\u0026 node test/verify-features.mjs \u0026\u0026 node test/boot-http.mjs \u0026\u0026 node test/pets-http.mjs \u0026\u0026 node test/pets-layer.mjs"
197
199
  },
198
200
  "peerDependencies": {
199
201
  "@deepseek-ai/cordis": "^4.0.4",