dsh-folia 2.5.0 → 2.7.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 +275 -32
- package/lib/routes/skins.js +84 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -16231,33 +16231,237 @@ window.__ModuleLoader__.load({
|
|
|
16231
16231
|
}
|
|
16232
16232
|
|
|
16233
16233
|
/**
|
|
16234
|
-
*
|
|
16234
|
+
* 把一套皮肤的样式**当场**挂进文档。
|
|
16235
16235
|
*
|
|
16236
|
-
*
|
|
16237
|
-
*
|
|
16238
|
-
*
|
|
16239
|
-
*
|
|
16236
|
+
* 为什么是 `<style>` 而不是 `<link>`:
|
|
16237
|
+
* · `<link rel=stylesheet>` 是**异步**的,加载完成的时机不定,容易输给皮肤中心随后
|
|
16238
|
+
* 重新注入的那份;
|
|
16239
|
+
* · `<style>` 一赋值就参与样式计算,且追加到 `<body>` **末尾** ⇒ 文档顺序最后,
|
|
16240
|
+
* 同等特异性下后写的赢。这是"不刷新换肤"能真正看见的关键。
|
|
16241
|
+
*
|
|
16242
|
+
* 仍然不跑 `hooks.mjs`(粒子等动效),那部分只有整页重载才生效。
|
|
16240
16243
|
*
|
|
16241
16244
|
* @param {string} id 皮肤 id
|
|
16245
|
+
* @returns {Promise<{ bytes: number, rules: number }>}
|
|
16246
|
+
*/
|
|
16247
|
+
async function applySkinCss(id) {
|
|
16248
|
+
const resp = await apiFetch("/skins/css?id=" + encodeURIComponent(id) + "&t=" + Date.now(), {
|
|
16249
|
+
parse: false,
|
|
16250
|
+
cache: "no-store",
|
|
16251
|
+
});
|
|
16252
|
+
if (!resp || !resp.ok || !resp.response) throw new Error("css-http-" + ((resp && resp.status) || 0));
|
|
16253
|
+
const css = await resp.response.text();
|
|
16254
|
+
if (!css || !css.trim()) throw new Error("css-empty");
|
|
16255
|
+
let el = document.getElementById("folia-skin-hot");
|
|
16256
|
+
if (!el) {
|
|
16257
|
+
el = document.createElement("style");
|
|
16258
|
+
el.id = "folia-skin-hot";
|
|
16259
|
+
// 挂 body 末尾(不是 head):文档顺序最后 ⇒ 同等特异性下覆盖先前的规则。
|
|
16260
|
+
(document.body || document.head).appendChild(el);
|
|
16261
|
+
}
|
|
16262
|
+
el.textContent = css;
|
|
16263
|
+
let rules = 0;
|
|
16264
|
+
try { rules = (el.sheet && el.sheet.cssRules && el.sheet.cssRules.length) || 0; } catch { /* 读不到就报 0 */ }
|
|
16265
|
+
return { bytes: css.length, rules };
|
|
16266
|
+
}
|
|
16267
|
+
|
|
16268
|
+
/**
|
|
16269
|
+
* 搭出皮肤的**背景层**:`body > [data-dsh-skin-layer="background"]`,z-index -2,
|
|
16270
|
+
* 里面放 `<video>`/`<img>`(object-fit: cover)+ 一层 scrim 渐变。
|
|
16271
|
+
*
|
|
16272
|
+
* 为什么必须自己搭:背景不在 CSS 里,而是 `contributes.backgroundMedia`;皮肤中心只在
|
|
16273
|
+
* 启动时建这个元素。热切换时没人建 ⇒ 只剩下配色变了、背景还是一片空 ——
|
|
16274
|
+
* 用户看到的"水墨丹青没有背景动画"就是这个(它是一段 21 MB 的循环视频)。
|
|
16275
|
+
* 元素契约(z-index -2、媒体作直接子元素)从各皮肤 CSS 反推:
|
|
16276
|
+
* `future-window` 写 `[data-dsh-skin-layer="background"] > img`,
|
|
16277
|
+
* `ice-princess` 的注释直接写明"挂在 z-index -2 这层上"。
|
|
16278
|
+
*
|
|
16279
|
+
* @param {string} id 皮肤 id
|
|
16280
|
+
* @returns {Promise<{ kind: string }>}
|
|
16281
|
+
*/
|
|
16282
|
+
async function applySkinBackground(id) {
|
|
16283
|
+
const resp = await apiJson("/skins/manifest?id=" + encodeURIComponent(id));
|
|
16284
|
+
const doc = (resp && resp.data) || null;
|
|
16285
|
+
const media = doc && doc.backgroundMedia;
|
|
16286
|
+
let dark = false;
|
|
16287
|
+
try { dark = Boolean(document.body && document.body.hasAttribute("data-ds-dark-theme")); } catch { /* 取不到按浅色 */ }
|
|
16288
|
+
const pick = media ? (dark ? (media.dark || media.light) : (media.light || media.dark)) : null;
|
|
16289
|
+
|
|
16290
|
+
const existing = document.querySelector('[data-dsh-skin-layer="background"]');
|
|
16291
|
+
if (!pick || !pick.url) {
|
|
16292
|
+
// 这套皮肤没有背景媒体:把上一次留下的层清掉,别串味。
|
|
16293
|
+
if (existing) existing.remove();
|
|
16294
|
+
return { kind: "none" };
|
|
16295
|
+
}
|
|
16296
|
+
|
|
16297
|
+
let layer = existing;
|
|
16298
|
+
if (!layer) {
|
|
16299
|
+
layer = document.createElement("div");
|
|
16300
|
+
layer.setAttribute("data-dsh-skin-layer", "background");
|
|
16301
|
+
layer.style.cssText = "position:fixed;inset:0;z-index:-2;pointer-events:none;overflow:hidden;";
|
|
16302
|
+
(document.body || document.documentElement).insertBefore(layer, document.body.firstChild);
|
|
16303
|
+
}
|
|
16304
|
+
layer.innerHTML = "";
|
|
16305
|
+
|
|
16306
|
+
let el;
|
|
16307
|
+
if (pick.type === "video") {
|
|
16308
|
+
el = document.createElement("video");
|
|
16309
|
+
el.autoplay = true;
|
|
16310
|
+
el.muted = true;
|
|
16311
|
+
el.loop = true;
|
|
16312
|
+
el.setAttribute("muted", "");
|
|
16313
|
+
el.setAttribute("playsinline", "");
|
|
16314
|
+
el.setAttribute("autoplay", "");
|
|
16315
|
+
el.setAttribute("loop", "");
|
|
16316
|
+
} else {
|
|
16317
|
+
el = document.createElement("img");
|
|
16318
|
+
el.alt = "";
|
|
16319
|
+
el.setAttribute("draggable", "false");
|
|
16320
|
+
}
|
|
16321
|
+
el.src = pick.url;
|
|
16322
|
+
// 皮肤 CSS 通过 `> img` / `> video` 选中它 ⇒ 必须是**直接子元素**。
|
|
16323
|
+
el.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;";
|
|
16324
|
+
layer.appendChild(el);
|
|
16325
|
+
|
|
16326
|
+
if (pick.scrim) {
|
|
16327
|
+
const scrim = document.createElement("div");
|
|
16328
|
+
scrim.style.cssText = "position:absolute;inset:0;pointer-events:none;background-image:" + pick.scrim + ";";
|
|
16329
|
+
layer.appendChild(scrim);
|
|
16330
|
+
}
|
|
16331
|
+
|
|
16332
|
+
if (pick.type === "video") {
|
|
16333
|
+
const kick = () => { try { const p = el.play(); if (p && typeof p.catch === "function") p.catch(() => {}); } catch { /* 自动播放被拒就静帧 */ } };
|
|
16334
|
+
el.addEventListener("loadeddata", kick, { once: true });
|
|
16335
|
+
kick();
|
|
16336
|
+
}
|
|
16337
|
+
return { kind: pick.type };
|
|
16338
|
+
}
|
|
16339
|
+
|
|
16340
|
+
/* ── 热切换运行时:作用域属性 + 六个装饰层 + hooks ───────────────────────────────
|
|
16341
|
+
*
|
|
16342
|
+
* 皮肤不是"一份 CSS"。它有三层,缺一层就"显示不全":
|
|
16343
|
+
* ① 样式:`stylesheet` + `patches`
|
|
16344
|
+
* ② 背景:`contributes.backgroundMedia`
|
|
16345
|
+
* ③ 作用域与装饰层:`html[data-dsh-skin="<id>"]` + 六个 `[data-dsh-skin-layer=...]`
|
|
16346
|
+
*
|
|
16347
|
+
* 第 ③ 项是"大部分皮肤看起来没生效"的真正原因 —— 65 套里有 **30 套**把规则整段挂在
|
|
16348
|
+
* `html[data-dsh-skin]` 下面,属性不设,那些规则一条都不匹配;另有 **28 套**带
|
|
16349
|
+
* `hooks.mjs`(粒子/交互),契约见皮肤中心 `contracts/hooks-api.d.ts`:
|
|
16350
|
+
* apply({ skinId, scopeAttr, assetBase, layers{...}, theme{get,subscribe}, onCleanup })
|
|
16351
|
+
* 所以这里补齐作用域属性、六个层,并按契约跑 hooks(失败只降级)。
|
|
16242
16352
|
*/
|
|
16243
|
-
|
|
16353
|
+
|
|
16354
|
+
const SKIN_LAYER_NAMES = ["background", "ambient", "bottom", "sidebar", "top", "foreground"];
|
|
16355
|
+
/** 本次激活注册的清理函数(切换皮肤时逐个跑掉,幂等)。 */
|
|
16356
|
+
let skinHotCleanups = [];
|
|
16357
|
+
|
|
16358
|
+
function skinThemeNow() {
|
|
16244
16359
|
try {
|
|
16245
|
-
|
|
16246
|
-
|
|
16360
|
+
return (document.body && document.body.hasAttribute("data-ds-dark-theme")) ? "dark" : "light";
|
|
16361
|
+
} catch {
|
|
16362
|
+
return "light";
|
|
16363
|
+
}
|
|
16364
|
+
}
|
|
16365
|
+
|
|
16366
|
+
/** 建立/复用六个装饰层,返回契约里那个 `layers` 对象。 */
|
|
16367
|
+
function ensureSkinLayers() {
|
|
16368
|
+
const host = document.body || document.documentElement;
|
|
16369
|
+
const out = {};
|
|
16370
|
+
for (const name of SKIN_LAYER_NAMES) {
|
|
16371
|
+
let el = null;
|
|
16372
|
+
try { el = document.querySelector('[data-dsh-skin-layer="' + name + '"]'); } catch { el = null; }
|
|
16247
16373
|
if (!el) {
|
|
16248
|
-
el = document.createElement("
|
|
16249
|
-
el.
|
|
16250
|
-
|
|
16251
|
-
|
|
16374
|
+
el = document.createElement("div");
|
|
16375
|
+
el.setAttribute("data-dsh-skin-layer", name);
|
|
16376
|
+
// 只给最必要的几何 + 不吃事件;层级交给皮肤自己的 CSS
|
|
16377
|
+
//(`background` 是契约里明写 z-index -2 的那层,其余留 auto)。
|
|
16378
|
+
el.style.cssText = "position:fixed;inset:0;pointer-events:none;"
|
|
16379
|
+
+ (name === "background" ? "z-index:-2;" : "");
|
|
16380
|
+
host.appendChild(el);
|
|
16252
16381
|
}
|
|
16253
|
-
|
|
16254
|
-
|
|
16255
|
-
|
|
16256
|
-
|
|
16382
|
+
out[name] = el;
|
|
16383
|
+
}
|
|
16384
|
+
return out;
|
|
16385
|
+
}
|
|
16386
|
+
|
|
16387
|
+
/**
|
|
16388
|
+
* 按契约跑一套皮肤的 hooks。失败**只降级**(返回原因),绝不打断样式/背景。
|
|
16389
|
+
* @returns {Promise<{ ran: boolean, reason?: string }>}
|
|
16390
|
+
*/
|
|
16391
|
+
async function applySkinHooks(id) {
|
|
16392
|
+
const resp = await apiJson("/skins/manifest?id=" + encodeURIComponent(id));
|
|
16393
|
+
const doc = (resp && resp.data) || null;
|
|
16394
|
+
const rel = doc && doc.hooks;
|
|
16395
|
+
if (!rel) return { ran: false, reason: "no-hooks" };
|
|
16396
|
+
|
|
16397
|
+
const encoded = String(rel).split("/").map(encodeURIComponent).join("/");
|
|
16398
|
+
const r = await apiFetch("/skins/files/" + encodeURIComponent(id) + "/" + encoded, {
|
|
16399
|
+
parse: false,
|
|
16400
|
+
cache: "no-store",
|
|
16401
|
+
});
|
|
16402
|
+
if (!r || !r.ok || !r.response) return { ran: false, reason: "hooks-http-" + ((r && r.status) || 0) };
|
|
16403
|
+
const source = await r.response.text();
|
|
16404
|
+
if (!source || !source.trim()) return { ran: false, reason: "hooks-empty" };
|
|
16405
|
+
|
|
16406
|
+
const blobUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
|
16407
|
+
try {
|
|
16408
|
+
const mod = await import(blobUrl);
|
|
16409
|
+
const factory = mod && typeof mod.default === "function" ? mod.default : null;
|
|
16410
|
+
if (!factory) return { ran: false, reason: "no-default-export" };
|
|
16411
|
+
const hooks = factory();
|
|
16412
|
+
if (!hooks || typeof hooks.apply !== "function") return { ran: false, reason: "no-apply" };
|
|
16413
|
+
|
|
16414
|
+
const layers = ensureSkinLayers();
|
|
16415
|
+
const unsubs = [];
|
|
16416
|
+
const ctx = {
|
|
16417
|
+
skinId: id,
|
|
16418
|
+
scopeAttr: id,
|
|
16419
|
+
assetBase: apiUrl("/skins/files/" + encodeURIComponent(id) + "/"),
|
|
16420
|
+
layers,
|
|
16421
|
+
theme: {
|
|
16422
|
+
get: skinThemeNow,
|
|
16423
|
+
subscribe(listener) {
|
|
16424
|
+
try {
|
|
16425
|
+
const obs = new MutationObserver(() => {
|
|
16426
|
+
try { listener(skinThemeNow()); } catch { /* 监听方出错不影响别人 */ }
|
|
16427
|
+
});
|
|
16428
|
+
obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
|
|
16429
|
+
const off = () => { try { obs.disconnect(); } catch { /* ignore */ } };
|
|
16430
|
+
unsubs.push(off);
|
|
16431
|
+
return off;
|
|
16432
|
+
} catch {
|
|
16433
|
+
return () => {};
|
|
16434
|
+
}
|
|
16435
|
+
},
|
|
16436
|
+
},
|
|
16437
|
+
onCleanup(fn) { if (typeof fn === "function") skinHotCleanups.push(fn); },
|
|
16438
|
+
};
|
|
16439
|
+
hooks.apply(ctx);
|
|
16440
|
+
if (typeof hooks.dispose === "function") {
|
|
16441
|
+
skinHotCleanups.push(() => { try { hooks.dispose(); } catch { /* 幂等 */ } });
|
|
16442
|
+
}
|
|
16443
|
+
skinHotCleanups.push(() => { for (const off of unsubs) { try { off(); } catch { /* ignore */ } } });
|
|
16444
|
+
return { ran: true };
|
|
16445
|
+
} finally {
|
|
16446
|
+
setTimeout(() => { try { URL.revokeObjectURL(blobUrl); } catch { /* ignore */ } }, 10000);
|
|
16257
16447
|
}
|
|
16258
16448
|
}
|
|
16259
16449
|
|
|
16260
|
-
|
|
16450
|
+
/** 撤掉上一次热切换留下的东西(清理函数 + 注入样式)。作用域属性由下一次直接覆盖。 */
|
|
16451
|
+
function clearSkinHot() {
|
|
16452
|
+
for (const fn of skinHotCleanups.splice(0).reverse()) {
|
|
16453
|
+
try { fn(); } catch { /* 清理幂等,单个失败不影响其余 */ }
|
|
16454
|
+
}
|
|
16455
|
+
try {
|
|
16456
|
+
const el = document.getElementById("folia-skin-hot");
|
|
16457
|
+
if (el) el.remove();
|
|
16458
|
+
} catch { /* ignore */ }
|
|
16459
|
+
}
|
|
16460
|
+
|
|
16461
|
+
function SkinsTabPanel(props) {
|
|
16462
|
+
// 壁纸让位:挂载点(设置页 / 首页抽屉)各自把"清掉当前壁纸"的回调传进来 ——
|
|
16463
|
+
// 皮肤和壁纸都在铺背景,必须二选一。本组件不直接碰设置,只调用传进来的入口。
|
|
16464
|
+
const onClearWallpaper = props && props.onClearWallpaper;
|
|
16261
16465
|
const [list, setList] = React.useState([]);
|
|
16262
16466
|
const [active, setActive] = React.useState(null);
|
|
16263
16467
|
const [loading, setLoading] = React.useState(true);
|
|
@@ -16319,15 +16523,52 @@ window.__ModuleLoader__.load({
|
|
|
16319
16523
|
setNote({ kind: "ok", text: weT("已安装:") + label });
|
|
16320
16524
|
await load(true);
|
|
16321
16525
|
} else if (kind === "activate") {
|
|
16322
|
-
//
|
|
16323
|
-
//
|
|
16324
|
-
|
|
16526
|
+
// **当场换肤**:把皮肤样式同步注入文档,不刷新、不重启。
|
|
16527
|
+
// 注入结果直接写进提示里(字节数 / 规则条数):万一样式还是没盖住皮肤中心,
|
|
16528
|
+
// 这一行就是判断"是没注入"还是"注入了但被压住"的依据,不用再猜。
|
|
16529
|
+
let info = null;
|
|
16530
|
+
let applyErr = "";
|
|
16531
|
+
let bgKind = "none";
|
|
16532
|
+
let hooksInfo = { ran: false, reason: "skipped" };
|
|
16533
|
+
try {
|
|
16534
|
+
// ① 先清掉上一次热切换留下的清理函数与注入样式(避免两套皮肤叠加)
|
|
16535
|
+
clearSkinHot();
|
|
16536
|
+
// ② 作用域属性:30/65 套皮肤的规则整段挂在 html[data-dsh-skin="<id>"] 下,
|
|
16537
|
+
// 不设这个属性,那些规则一条都不匹配 —— 这是"显示不全"的最大来源。
|
|
16538
|
+
try { document.documentElement.setAttribute("data-dsh-skin", id); } catch { /* ignore */ }
|
|
16539
|
+
// ③ 六个装饰层(hooks 与部分皮肤 CSS 都要用)
|
|
16540
|
+
ensureSkinLayers();
|
|
16541
|
+
// ④ 样式、⑤ 背景、⑥ hooks
|
|
16542
|
+
info = await applySkinCss(id);
|
|
16543
|
+
const bg = await applySkinBackground(id);
|
|
16544
|
+
bgKind = bg.kind;
|
|
16545
|
+
try {
|
|
16546
|
+
hooksInfo = await applySkinHooks(id);
|
|
16547
|
+
} catch (e) {
|
|
16548
|
+
hooksInfo = { ran: false, reason: String((e && e.message) || e) };
|
|
16549
|
+
}
|
|
16550
|
+
} catch (e) {
|
|
16551
|
+
applyErr = String((e && e.message) || e);
|
|
16552
|
+
}
|
|
16553
|
+
// ⑦ 壁纸让位:皮肤和壁纸都在铺背景,必须互斥。清掉 `id` 之后壁纸层会自己撤下
|
|
16554
|
+
// (同时 `data-we-wallpaper` 标记消失,皮肤中心也不会再扣住皮肤)。
|
|
16555
|
+
let wallpaperCleared = false;
|
|
16556
|
+
try {
|
|
16557
|
+
if (typeof onClearWallpaper === "function") {
|
|
16558
|
+
await Promise.resolve(onClearWallpaper());
|
|
16559
|
+
wallpaperCleared = true;
|
|
16560
|
+
}
|
|
16561
|
+
} catch { /* 清壁纸失败不影响换肤本身 */ }
|
|
16325
16562
|
setNote({
|
|
16326
|
-
kind: "ok",
|
|
16327
|
-
text:
|
|
16328
|
-
? weT("
|
|
16329
|
-
: weT("
|
|
16330
|
-
|
|
16563
|
+
kind: applyErr ? "err" : "ok",
|
|
16564
|
+
text: applyErr
|
|
16565
|
+
? weT("已切换为「") + label + weT("」,但样式注入失败:") + applyErr
|
|
16566
|
+
: weT("已切换到「") + label + weT("」—— 样式 ")
|
|
16567
|
+
+ Math.round(info.bytes / 1024) + " KB / " + info.rules + weT(" 条规则")
|
|
16568
|
+
+ (bgKind === "none" ? weT(",无背景媒体") : weT(",背景:") + bgKind)
|
|
16569
|
+
+ (hooksInfo.ran ? weT(",动效已启动") : (hooksInfo.reason === "no-hooks" ? weT(",无动效") : weT(",动效未启动(") + hooksInfo.reason + ")"))
|
|
16570
|
+
+ (wallpaperCleared ? weT(",壁纸已关闭") : ""),
|
|
16571
|
+
// hooks 已经跑过了;这里只作为"整页重载"的可选入口(极少数皮肤依赖启动期布局)
|
|
16331
16572
|
reload: true,
|
|
16332
16573
|
});
|
|
16333
16574
|
// 不再整表重拉:本地标记已经对了,省掉那 600 ms 的"没反应"窗口。
|
|
@@ -16528,8 +16769,6 @@ window.__ModuleLoader__.load({
|
|
|
16528
16769
|
: React.createElement("div", { style: SKINS_STYLE.thumb }),
|
|
16529
16770
|
React.createElement("div", { style: SKINS_STYLE.body },
|
|
16530
16771
|
React.createElement("div", { style: SKINS_STYLE.title }, s.name || s.id),
|
|
16531
|
-
React.createElement("div", { style: SKINS_STYLE.meta },
|
|
16532
|
-
(s.author ? s.author : weT("未知作者")) + " · " + skinsFmtBytes(s.zipBytes)),
|
|
16533
16772
|
s.tagline ? React.createElement("div", { style: SKINS_STYLE.meta }, s.tagline) : null,
|
|
16534
16773
|
(s.tags || []).length
|
|
16535
16774
|
? React.createElement("div", { style: SKINS_STYLE.tags },
|
|
@@ -16544,9 +16783,13 @@ window.__ModuleLoader__.load({
|
|
|
16544
16783
|
return React.createElement(React.Fragment, null, children);
|
|
16545
16784
|
}
|
|
16546
16785
|
|
|
16547
|
-
/**
|
|
16548
|
-
|
|
16549
|
-
|
|
16786
|
+
/**
|
|
16787
|
+
* 页签薄包装:`renderActiveTab` 只认这个签名(与别的 `render*Tab(ctx)` 一致)。
|
|
16788
|
+
* 传进来的 `props` 原样转交给组件 —— 目前只有 `onClearWallpaper`(皮肤与壁纸互斥,
|
|
16789
|
+
* 由挂载点提供"清掉当前壁纸"的入口;本文件不直接碰设置)。
|
|
16790
|
+
*/
|
|
16791
|
+
function renderSkinsTab(props) {
|
|
16792
|
+
return React.createElement(SkinsTabPanel, props || null);
|
|
16550
16793
|
}
|
|
16551
16794
|
|
|
16552
16795
|
// ── 内联模块:src/picker-model.js —— 选择器模型层:过滤判定 + 派生数据 + 分页切片(纯函数 / 显式入参 / 零 DOM)(构建期注入;勿手改本段)──
|
|
@@ -18155,7 +18398,7 @@ window.__ModuleLoader__.load({
|
|
|
18155
18398
|
? qpRenderPlaybackPane(sel, switchQpTab)
|
|
18156
18399
|
// 皮肤包:直接挂设置页那个同一个组件(它自己管列表/下载/激活状态)。
|
|
18157
18400
|
: qpTab === "skins"
|
|
18158
|
-
? React.createElement(SkinsTabPanel,
|
|
18401
|
+
? React.createElement(SkinsTabPanel, { onClearWallpaper: () => setSetting("id", "") })
|
|
18159
18402
|
: null),
|
|
18160
18403
|
!(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && qpTab === "wallpaper" && React.createElement(React.Fragment, null,
|
|
18161
18404
|
// ── 壁纸列表(搜索 + 视图切换 + 快切)──
|
|
@@ -29773,7 +30016,7 @@ window.__ModuleLoader__.load({
|
|
|
29773
30016
|
if (activeTab === "extensions") return renderExtensionsTab(extensionCtx());
|
|
29774
30017
|
// 「皮肤包」自带异步状态(列表 / 下载中 / 错误),所以它是一个**真组件**而不是
|
|
29775
30018
|
// 纯渲染函数 —— 薄包装只负责挂上去(理由见 src/panel-tabs-skins.js 文件头)。
|
|
29776
|
-
if (activeTab === "skins") return renderSkinsTab();
|
|
30019
|
+
if (activeTab === "skins") return renderSkinsTab({ onClearWallpaper: () => setSetting("id", "") });
|
|
29777
30020
|
if (activeTab === "appearance") return renderAppearanceTab({
|
|
29778
30021
|
setSetting, setTransient,
|
|
29779
30022
|
fontSet: fontSetCtx(),
|
package/lib/routes/skins.js
CHANGED
|
@@ -544,12 +544,62 @@ export function registerSkinRoutes(webServer, c) {
|
|
|
544
544
|
'.webp': 'image/webp',
|
|
545
545
|
'.gif': 'image/gif',
|
|
546
546
|
'.avif': 'image/avif',
|
|
547
|
+
'.mp4': 'video/mp4',
|
|
548
|
+
'.webm': 'video/webm',
|
|
547
549
|
'.woff': 'font/woff',
|
|
548
550
|
'.woff2': 'font/woff2',
|
|
549
551
|
'.ttf': 'font/ttf',
|
|
550
552
|
'.otf': 'font/otf',
|
|
551
553
|
};
|
|
552
554
|
|
|
555
|
+
/* ── 背景层数据 ────────────────────────────────────────────────────────────
|
|
556
|
+
* 一套皮肤的"背景"不在 CSS 里,而是 `contributes.backgroundMedia`:
|
|
557
|
+
* { light: { type: 'video'|'image', src: 'assets/x.mp4', scrim: '<css 渐变>' }, dark: {...} }
|
|
558
|
+
* 皮肤中心负责把这层画出来;它的 DOM 契约(从各皮肤 CSS 反推)是
|
|
559
|
+
* `body [data-dsh-skin-layer='background']`,z-index -2,视频/图片作为子元素
|
|
560
|
+
*(`future-window` 直接写 `> img`,`frutiger-aero` 用 `::after` 叠气泡)。
|
|
561
|
+
* 热切换时没人建这层 ⇒ 背景全空("水墨丹青没有背景动画"就是这个原因)。
|
|
562
|
+
*/
|
|
563
|
+
disposers.push(webServer.register({
|
|
564
|
+
kind: 'exact',
|
|
565
|
+
path: `${base}/skins/manifest`,
|
|
566
|
+
handler: async (req, res) => {
|
|
567
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
568
|
+
const id = url.searchParams.get('id') || '';
|
|
569
|
+
if (!ID_RE.test(id)) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
570
|
+
const harness = harnessRoot();
|
|
571
|
+
if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
|
|
572
|
+
const dir = join(harness, SKINS_DIR_NAME, id);
|
|
573
|
+
let doc;
|
|
574
|
+
try {
|
|
575
|
+
doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
|
|
576
|
+
} catch {
|
|
577
|
+
sendJson(res, 404, { ok: false, error: 'no-manifest' });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const c = (doc && doc.contributes) || {};
|
|
581
|
+
const media = c.backgroundMedia && typeof c.backgroundMedia === 'object' ? c.backgroundMedia : null;
|
|
582
|
+
/** `src` 相对路径 → 我们的素材路由(浏览器只认这条)。 */
|
|
583
|
+
const abs = (rel) => {
|
|
584
|
+
if (typeof rel !== 'string' || !rel) return '';
|
|
585
|
+
if (/^(?:[a-z]+:|\/)/i.test(rel)) return rel;
|
|
586
|
+
return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
|
|
587
|
+
};
|
|
588
|
+
const shape = (m) => (m && typeof m === 'object' ? {
|
|
589
|
+
type: m.type === 'video' ? 'video' : 'image',
|
|
590
|
+
url: abs(m.src),
|
|
591
|
+
scrim: typeof m.scrim === 'string' ? m.scrim : '',
|
|
592
|
+
} : null);
|
|
593
|
+
sendJson(res, 200, {
|
|
594
|
+
ok: true,
|
|
595
|
+
id,
|
|
596
|
+
name: typeof doc.name === 'string' ? doc.name : id,
|
|
597
|
+
backgroundMedia: media ? { light: shape(media.light), dark: shape(media.dark) } : null,
|
|
598
|
+
hooks: typeof c.hooks === 'string' ? c.hooks : '',
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
}));
|
|
602
|
+
|
|
553
603
|
disposers.push(webServer.register({
|
|
554
604
|
kind: 'exact',
|
|
555
605
|
path: `${base}/skins/css`,
|
|
@@ -596,6 +646,40 @@ export function registerSkinRoutes(webServer, c) {
|
|
|
596
646
|
},
|
|
597
647
|
}));
|
|
598
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
|
+
|
|
599
683
|
disposers.push(webServer.register({
|
|
600
684
|
kind: 'exact',
|
|
601
685
|
path: `${base}/skins/asset`,
|