dsh-folia 2.6.0 → 2.8.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
@@ -16320,12 +16320,13 @@ window.__ModuleLoader__.load({
16320
16320
  }
16321
16321
  el.src = pick.url;
16322
16322
  // 皮肤 CSS 通过 `> img` / `> video` 选中它 ⇒ 必须是**直接子元素**。
16323
+ el.setAttribute("aria-hidden", "true");
16323
16324
  el.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;";
16324
16325
  layer.appendChild(el);
16325
16326
 
16326
16327
  if (pick.scrim) {
16327
16328
  const scrim = document.createElement("div");
16328
- scrim.style.cssText = "position:absolute;inset:0;pointer-events:none;background-image:" + pick.scrim + ";";
16329
+ scrim.style.cssText = "position:absolute;inset:0;pointer-events:none;background:" + pick.scrim + ";";
16329
16330
  layer.appendChild(scrim);
16330
16331
  }
16331
16332
 
@@ -16337,7 +16338,152 @@ window.__ModuleLoader__.load({
16337
16338
  return { kind: pick.type };
16338
16339
  }
16339
16340
 
16340
- function SkinsTabPanel() {
16341
+ /* ── 热切换运行时:作用域属性 + 六个装饰层 + hooks ───────────────────────────────
16342
+ *
16343
+ * 皮肤不是"一份 CSS"。它有三层,缺一层就"显示不全":
16344
+ * ① 样式:`stylesheet` + `patches`
16345
+ * ② 背景:`contributes.backgroundMedia`
16346
+ * ③ 作用域与装饰层:`html[data-dsh-skin="<id>"]` + 六个 `[data-dsh-skin-layer=...]`
16347
+ *
16348
+ * 第 ③ 项是"大部分皮肤看起来没生效"的真正原因 —— 65 套里有 **30 套**把规则整段挂在
16349
+ * `html[data-dsh-skin]` 下面,属性不设,那些规则一条都不匹配;另有 **28 套**带
16350
+ * `hooks.mjs`(粒子/交互),契约见皮肤中心 `contracts/hooks-api.d.ts`:
16351
+ * apply({ skinId, scopeAttr, assetBase, layers{...}, theme{get,subscribe}, onCleanup })
16352
+ * 所以这里补齐作用域属性、六个层,并按契约跑 hooks(失败只降级)。
16353
+ */
16354
+
16355
+ const SKIN_LAYER_NAMES = ["background", "ambient", "bottom", "sidebar", "top", "foreground"];
16356
+ /**
16357
+ * 六层的**精确**样式:逐字对齐皮肤中心 `runtime/decoration-layers.ts` 的 `LAYER_STYLE`。
16358
+ *
16359
+ * 不能自己拍脑袋定层级 —— 它的注释写明了为什么:
16360
+ * · background 用 **-2**:负 z-index 元素画在 html/body 背景**之上**(皮肤自己的
16361
+ * 不透明底色因此在画后面、艺术图在它前面),但**在所有面板之下**;
16362
+ * · ambient 30、top/bottom/sidebar 40、foreground 41:压在面板之上、官方浮层
16363
+ * (>=1000)之下。之前我把非背景层留成 auto ⇒ 装饰条落进文档流最底层,
16364
+ * 表现正是"上面被面板盖住、只有下面露出背景"。
16365
+ */
16366
+ const SKIN_LAYER_STYLE = {
16367
+ background: "position:fixed;top:0;right:0;bottom:0;left:0;z-index:-2;pointer-events:none;will-change:transform;",
16368
+ ambient: "position:fixed;top:0;right:0;bottom:0;left:0;z-index:30;pointer-events:none;",
16369
+ top: "position:fixed;top:0;left:0;right:0;z-index:40;pointer-events:none;",
16370
+ bottom: "position:fixed;bottom:0;left:0;right:0;z-index:40;pointer-events:none;",
16371
+ sidebar: "position:fixed;top:0;bottom:0;left:0;z-index:40;pointer-events:none;",
16372
+ foreground: "position:fixed;top:0;right:0;bottom:0;left:0;z-index:41;pointer-events:none;",
16373
+ };
16374
+ /** 本次激活注册的清理函数(切换皮肤时逐个跑掉,幂等)。 */
16375
+ let skinHotCleanups = [];
16376
+
16377
+ function skinThemeNow() {
16378
+ try {
16379
+ return (document.body && document.body.hasAttribute("data-ds-dark-theme")) ? "dark" : "light";
16380
+ } catch {
16381
+ return "light";
16382
+ }
16383
+ }
16384
+
16385
+ /** 建立/复用六个装饰层,返回契约里那个 `layers` 对象。 */
16386
+ function ensureSkinLayers() {
16387
+ const host = document.body || document.documentElement;
16388
+ const out = {};
16389
+ for (const name of SKIN_LAYER_NAMES) {
16390
+ let el = null;
16391
+ try { el = document.querySelector('[data-dsh-skin-layer="' + name + '"]'); } catch { el = null; }
16392
+ if (!el) {
16393
+ el = document.createElement("div");
16394
+ el.setAttribute("data-dsh-skin-layer", name);
16395
+ el.setAttribute("aria-hidden", "true");
16396
+ host.appendChild(el);
16397
+ }
16398
+ // 与它的 ensureOne 同口径:**每次都对已有层重写样式** —— 皮肤中心的层可能带着
16399
+ // 上一次激活留下的内联覆盖,只建不改会继承脏值。
16400
+ el.style.cssText = SKIN_LAYER_STYLE[name];
16401
+ out[name] = el;
16402
+ }
16403
+ // 皮肤中心自己会给 body 盖 `data-dsh-skin-center`(它的若干基础样式挂在这个前缀下),
16404
+ // 缺了它,面板/列表那些"表面透明"的规则不生效 → 大片面板仍然不透明。
16405
+ try { document.body.setAttribute("data-dsh-skin-center", ""); } catch { /* ignore */ }
16406
+ return out;
16407
+ }
16408
+
16409
+ /**
16410
+ * 按契约跑一套皮肤的 hooks。失败**只降级**(返回原因),绝不打断样式/背景。
16411
+ * @returns {Promise<{ ran: boolean, reason?: string }>}
16412
+ */
16413
+ async function applySkinHooks(id) {
16414
+ const resp = await apiJson("/skins/manifest?id=" + encodeURIComponent(id));
16415
+ const doc = (resp && resp.data) || null;
16416
+ const rel = doc && doc.hooks;
16417
+ if (!rel) return { ran: false, reason: "no-hooks" };
16418
+
16419
+ const encoded = String(rel).split("/").map(encodeURIComponent).join("/");
16420
+ const r = await apiFetch("/skins/files/" + encodeURIComponent(id) + "/" + encoded, {
16421
+ parse: false,
16422
+ cache: "no-store",
16423
+ });
16424
+ if (!r || !r.ok || !r.response) return { ran: false, reason: "hooks-http-" + ((r && r.status) || 0) };
16425
+ const source = await r.response.text();
16426
+ if (!source || !source.trim()) return { ran: false, reason: "hooks-empty" };
16427
+
16428
+ const blobUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
16429
+ try {
16430
+ const mod = await import(blobUrl);
16431
+ const factory = mod && typeof mod.default === "function" ? mod.default : null;
16432
+ if (!factory) return { ran: false, reason: "no-default-export" };
16433
+ const hooks = factory();
16434
+ if (!hooks || typeof hooks.apply !== "function") return { ran: false, reason: "no-apply" };
16435
+
16436
+ const layers = ensureSkinLayers();
16437
+ const unsubs = [];
16438
+ const ctx = {
16439
+ skinId: id,
16440
+ scopeAttr: id,
16441
+ assetBase: apiUrl("/skins/files/" + encodeURIComponent(id) + "/"),
16442
+ layers,
16443
+ theme: {
16444
+ get: skinThemeNow,
16445
+ subscribe(listener) {
16446
+ try {
16447
+ const obs = new MutationObserver(() => {
16448
+ try { listener(skinThemeNow()); } catch { /* 监听方出错不影响别人 */ }
16449
+ });
16450
+ obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
16451
+ const off = () => { try { obs.disconnect(); } catch { /* ignore */ } };
16452
+ unsubs.push(off);
16453
+ return off;
16454
+ } catch {
16455
+ return () => {};
16456
+ }
16457
+ },
16458
+ },
16459
+ onCleanup(fn) { if (typeof fn === "function") skinHotCleanups.push(fn); },
16460
+ };
16461
+ hooks.apply(ctx);
16462
+ if (typeof hooks.dispose === "function") {
16463
+ skinHotCleanups.push(() => { try { hooks.dispose(); } catch { /* 幂等 */ } });
16464
+ }
16465
+ skinHotCleanups.push(() => { for (const off of unsubs) { try { off(); } catch { /* ignore */ } } });
16466
+ return { ran: true };
16467
+ } finally {
16468
+ setTimeout(() => { try { URL.revokeObjectURL(blobUrl); } catch { /* ignore */ } }, 10000);
16469
+ }
16470
+ }
16471
+
16472
+ /** 撤掉上一次热切换留下的东西(清理函数 + 注入样式)。作用域属性由下一次直接覆盖。 */
16473
+ function clearSkinHot() {
16474
+ for (const fn of skinHotCleanups.splice(0).reverse()) {
16475
+ try { fn(); } catch { /* 清理幂等,单个失败不影响其余 */ }
16476
+ }
16477
+ try {
16478
+ const el = document.getElementById("folia-skin-hot");
16479
+ if (el) el.remove();
16480
+ } catch { /* ignore */ }
16481
+ }
16482
+
16483
+ function SkinsTabPanel(props) {
16484
+ // 壁纸让位:挂载点(设置页 / 首页抽屉)各自把"清掉当前壁纸"的回调传进来 ——
16485
+ // 皮肤和壁纸都在铺背景,必须二选一。本组件不直接碰设置,只调用传进来的入口。
16486
+ const onClearWallpaper = props && props.onClearWallpaper;
16341
16487
  const [list, setList] = React.useState([]);
16342
16488
  const [active, setActive] = React.useState(null);
16343
16489
  const [loading, setLoading] = React.useState(true);
@@ -16405,23 +16551,46 @@ window.__ModuleLoader__.load({
16405
16551
  let info = null;
16406
16552
  let applyErr = "";
16407
16553
  let bgKind = "none";
16554
+ let hooksInfo = { ran: false, reason: "skipped" };
16408
16555
  try {
16556
+ // ① 先清掉上一次热切换留下的清理函数与注入样式(避免两套皮肤叠加)
16557
+ clearSkinHot();
16558
+ // ② 作用域属性:30/65 套皮肤的规则整段挂在 html[data-dsh-skin="<id>"] 下,
16559
+ // 不设这个属性,那些规则一条都不匹配 —— 这是"显示不全"的最大来源。
16560
+ try { document.documentElement.setAttribute("data-dsh-skin", id); } catch { /* ignore */ }
16561
+ // ③ 六个装饰层(hooks 与部分皮肤 CSS 都要用)
16562
+ ensureSkinLayers();
16563
+ // ④ 样式、⑤ 背景、⑥ hooks
16409
16564
  info = await applySkinCss(id);
16410
- // 样式之后立刻搭背景层:没有它,凡是靠 backgroundMedia 的皮肤(水墨丹青、
16411
- // 鲸鱼娘、深海女仆工坊…)都只会"变配色不变画面"。
16412
16565
  const bg = await applySkinBackground(id);
16413
16566
  bgKind = bg.kind;
16567
+ try {
16568
+ hooksInfo = await applySkinHooks(id);
16569
+ } catch (e) {
16570
+ hooksInfo = { ran: false, reason: String((e && e.message) || e) };
16571
+ }
16414
16572
  } catch (e) {
16415
16573
  applyErr = String((e && e.message) || e);
16416
16574
  }
16575
+ // ⑦ 壁纸让位:皮肤和壁纸都在铺背景,必须互斥。清掉 `id` 之后壁纸层会自己撤下
16576
+ // (同时 `data-we-wallpaper` 标记消失,皮肤中心也不会再扣住皮肤)。
16577
+ let wallpaperCleared = false;
16578
+ try {
16579
+ if (typeof onClearWallpaper === "function") {
16580
+ await Promise.resolve(onClearWallpaper());
16581
+ wallpaperCleared = true;
16582
+ }
16583
+ } catch { /* 清壁纸失败不影响换肤本身 */ }
16417
16584
  setNote({
16418
16585
  kind: applyErr ? "err" : "ok",
16419
16586
  text: applyErr
16420
16587
  ? weT("已切换为「") + label + weT("」,但样式注入失败:") + applyErr
16421
16588
  : weT("已切换到「") + label + weT("」—— 样式 ")
16422
16589
  + Math.round(info.bytes / 1024) + " KB / " + info.rules + weT(" 条规则")
16423
- + (bgKind === "none" ? weT(",该皮肤无背景媒体") : weT(",背景:") + bgKind),
16424
- // hooks(粒子等动效)只有整页重载才会跑:给一个可选入口,不强制。
16590
+ + (bgKind === "none" ? weT(",无背景媒体") : weT(",背景:") + bgKind)
16591
+ + (hooksInfo.ran ? weT(",动效已启动") : (hooksInfo.reason === "no-hooks" ? weT(",无动效") : weT(",动效未启动(") + hooksInfo.reason + ")"))
16592
+ + (wallpaperCleared ? weT(",壁纸已关闭") : ""),
16593
+ // hooks 已经跑过了;这里只作为"整页重载"的可选入口(极少数皮肤依赖启动期布局)
16425
16594
  reload: true,
16426
16595
  });
16427
16596
  // 不再整表重拉:本地标记已经对了,省掉那 600 ms 的"没反应"窗口。
@@ -16636,9 +16805,13 @@ window.__ModuleLoader__.load({
16636
16805
  return React.createElement(React.Fragment, null, children);
16637
16806
  }
16638
16807
 
16639
- /** 页签薄包装:`renderActiveTab` 只认这个签名(与别的 `render*Tab(ctx)` 一致)。 */
16640
- function renderSkinsTab(ctx) {
16641
- return React.createElement(SkinsTabPanel, null);
16808
+ /**
16809
+ * 页签薄包装:`renderActiveTab` 只认这个签名(与别的 `render*Tab(ctx)` 一致)。
16810
+ * 传进来的 `props` 原样转交给组件 —— 目前只有 `onClearWallpaper`(皮肤与壁纸互斥,
16811
+ * 由挂载点提供"清掉当前壁纸"的入口;本文件不直接碰设置)。
16812
+ */
16813
+ function renderSkinsTab(props) {
16814
+ return React.createElement(SkinsTabPanel, props || null);
16642
16815
  }
16643
16816
 
16644
16817
  // ── 内联模块:src/picker-model.js —— 选择器模型层:过滤判定 + 派生数据 + 分页切片(纯函数 / 显式入参 / 零 DOM)(构建期注入;勿手改本段)──
@@ -18247,7 +18420,7 @@ window.__ModuleLoader__.load({
18247
18420
  ? qpRenderPlaybackPane(sel, switchQpTab)
18248
18421
  // 皮肤包:直接挂设置页那个同一个组件(它自己管列表/下载/激活状态)。
18249
18422
  : qpTab === "skins"
18250
- ? React.createElement(SkinsTabPanel, null)
18423
+ ? React.createElement(SkinsTabPanel, { onClearWallpaper: () => setSetting("id", "") })
18251
18424
  : null),
18252
18425
  !(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && qpTab === "wallpaper" && React.createElement(React.Fragment, null,
18253
18426
  // ── 壁纸列表(搜索 + 视图切换 + 快切)──
@@ -29865,7 +30038,7 @@ window.__ModuleLoader__.load({
29865
30038
  if (activeTab === "extensions") return renderExtensionsTab(extensionCtx());
29866
30039
  // 「皮肤包」自带异步状态(列表 / 下载中 / 错误),所以它是一个**真组件**而不是
29867
30040
  // 纯渲染函数 —— 薄包装只负责挂上去(理由见 src/panel-tabs-skins.js 文件头)。
29868
- if (activeTab === "skins") return renderSkinsTab();
30041
+ if (activeTab === "skins") return renderSkinsTab({ onClearWallpaper: () => setSetting("id", "") });
29869
30042
  if (activeTab === "appearance") return renderAppearanceTab({
29870
30043
  setSetting, setTransient,
29871
30044
  fontSet: fontSetCtx(),
@@ -646,6 +646,40 @@ export function registerSkinRoutes(webServer, c) {
646
646
  },
647
647
  }));
648
648
 
649
+ /* ── 路径式素材路由 ────────────────────────────────────────────────────────
650
+ * 皮肤的 hooks 契约(`contracts/hooks-api.d.ts`)给的是 `assetBase`:一个**同源 URL
651
+ * 前缀**,hooks 里自己拼 `assetBase + 'assets/x.webp'`。`?id=&p=` 这种查询式前缀拼不出
652
+ * 合法地址,所以这里再提供一条路径式:`/skins/files/<id>/<相对路径>`。
653
+ * 与 asset 完全同一套安全校验(id 白名单 + 路径段 + 越界检查)。
654
+ */
655
+ disposers.push(webServer.register({
656
+ kind: 'prefix',
657
+ path: `${base}/skins/files`,
658
+ handler: async (req, res) => {
659
+ let pathname = '/';
660
+ try { pathname = new URL(req.url || '/', 'http://x').pathname; } catch { res.statusCode = 400; res.end(); return; }
661
+ const prefix = `${base}/skins/files/`;
662
+ if (!pathname.startsWith(prefix)) { res.statusCode = 404; res.end(); return; }
663
+ const rest = decodeURIComponent(pathname.slice(prefix.length));
664
+ const slash = rest.indexOf('/');
665
+ const id = slash < 0 ? rest : rest.slice(0, slash);
666
+ const rel = slash < 0 ? '' : rest.slice(slash + 1);
667
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
668
+ const segs = safeRelPath(rel);
669
+ if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
670
+ const harness = harnessRoot();
671
+ if (!harness) { res.statusCode = 503; res.end(); return; }
672
+ const root = join(harness, SKINS_DIR_NAME, id);
673
+ const file = join(root, ...segs);
674
+ if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
675
+ if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
676
+ const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
677
+ res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
678
+ res.setHeader('Cache-Control', 'public, max-age=300');
679
+ res.end(readFileSync(file));
680
+ },
681
+ }));
682
+
649
683
  disposers.push(webServer.register({
650
684
  kind: 'exact',
651
685
  path: `${base}/skins/asset`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {