dsh-folia 2.6.0 → 2.9.2
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 +345 -34
- package/lib/routes/skins.js +97 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -16231,38 +16231,80 @@ window.__ModuleLoader__.load({
|
|
|
16231
16231
|
}
|
|
16232
16232
|
|
|
16233
16233
|
/**
|
|
16234
|
-
*
|
|
16234
|
+
* 取一套皮肤**最终该用的 CSS**。
|
|
16235
|
+
*
|
|
16236
|
+
* 关键:皮肤中心不是把皮肤目录里的 CSS 原样发出去的 ——
|
|
16237
|
+
* `GET /api/skin-center/v2/skins/<id>/stylesheet` → `transformSkinCss()`:**强制作用域化**
|
|
16238
|
+
* `GET /api/skin-center/v2/skins/<id>/patches` → 同上(无则 404)
|
|
16239
|
+
* 作用域化 = 把每条规则包进 `html[data-dsh-skin="<id>"]`。这一步直接决定成败:
|
|
16240
|
+
* **包了作用域的规则特异性更高**,才压得过应用自身的面板/令牌规则。我们早先注入的是
|
|
16241
|
+
* 目录里的裸 CSS,特异性与应用同级 ⇒ 大量规则输掉 ⇒ 配色变了一点、面板依旧不透明。
|
|
16235
16242
|
*
|
|
16236
|
-
*
|
|
16237
|
-
*
|
|
16238
|
-
*
|
|
16239
|
-
*
|
|
16240
|
-
|
|
16243
|
+
* 所以优先**直接用它加工好的那份**(同源,浏览器自带凭证);只有拿不到才退回我们自己的
|
|
16244
|
+
* `/folia/skins/css`(未作用域化,能变配色但压不住应用规则)。
|
|
16245
|
+
*
|
|
16246
|
+
* @returns {Promise<{ css: string, source: string }>}
|
|
16247
|
+
*/
|
|
16248
|
+
async function fetchSkinCss(id) {
|
|
16249
|
+
const enc = encodeURIComponent(id);
|
|
16250
|
+
const parts = [];
|
|
16251
|
+
let source = "";
|
|
16252
|
+
|
|
16253
|
+
// ① 宿主端代理由它自己去问皮肤中心(带上本次请求的 Cookie 与同源头)。
|
|
16254
|
+
// 前端直连 `/api/skin-center/...` 会被 DSH 网关挡掉,实测一直落到"未作用域化"。
|
|
16255
|
+
try {
|
|
16256
|
+
const viaHost = await apiFetch("/skins/scoped-css?id=" + enc, { parse: false, cache: "no-store" });
|
|
16257
|
+
if (viaHost && viaHost.ok && viaHost.response) {
|
|
16258
|
+
const text = await viaHost.response.text();
|
|
16259
|
+
if (text && text.trim()) return { css: text, source: "skin-center" };
|
|
16260
|
+
}
|
|
16261
|
+
} catch { /* 代理不可用 ⇒ 继续走下面两条 */ }
|
|
16262
|
+
|
|
16263
|
+
// ② 前端直连(皮肤中心同源路由,能通的话最省一次往返)
|
|
16264
|
+
for (const part of ["stylesheet", "patches"]) {
|
|
16265
|
+
try {
|
|
16266
|
+
// 根相对路径 + 原生请求:桌面壳可能跑在 `dsh-app://` 下(`location.origin` 是 "null"),
|
|
16267
|
+
// 拼绝对 URL 会作废;`/api/...` 交给浏览器按文档解析才可靠
|
|
16268
|
+
//(皮肤中心自己的客户端用的也是这种写法)。
|
|
16269
|
+
const r = await apiRawResponse("/api/skin-center/v2/skins/" + enc + "/" + part);
|
|
16270
|
+
if (r && r.ok) {
|
|
16271
|
+
const text = await r.text();
|
|
16272
|
+
if (text && text.trim()) {
|
|
16273
|
+
parts.push(text);
|
|
16274
|
+
source = "skin-center";
|
|
16275
|
+
}
|
|
16276
|
+
}
|
|
16277
|
+
} catch { /* 拿不到就试下一段 / 退回自己的路由 */ }
|
|
16278
|
+
}
|
|
16279
|
+
if (parts.length > 0) return { css: parts.join("\n/* ---- patches ---- */\n"), source };
|
|
16280
|
+
const r2 = await apiFetch("/skins/css?id=" + enc + "&t=" + Date.now(), { parse: false, cache: "no-store" });
|
|
16281
|
+
if (!r2 || !r2.ok || !r2.response) throw new Error("css-http-" + ((r2 && r2.status) || 0));
|
|
16282
|
+
const text = await r2.response.text();
|
|
16283
|
+
if (!text || !text.trim()) throw new Error("css-empty");
|
|
16284
|
+
return { css: text, source: "folia-raw" };
|
|
16285
|
+
}
|
|
16286
|
+
|
|
16287
|
+
/**
|
|
16288
|
+
* 把一套皮肤的样式**当场**挂进文档。
|
|
16241
16289
|
*
|
|
16242
|
-
*
|
|
16290
|
+
* 用 `<style>` 而不是 `<link>`:`<style>` 一赋值就参与样式计算,且追加到 `<body>` 末尾
|
|
16291
|
+
* ⇒ 文档顺序最后、同等特异性下后写的赢;`<link>` 是异步的,顺序不由我们定。
|
|
16243
16292
|
*
|
|
16244
16293
|
* @param {string} id 皮肤 id
|
|
16245
|
-
* @returns {Promise<{ bytes: number, rules: number }>}
|
|
16294
|
+
* @returns {Promise<{ bytes: number, rules: number, source: string }>}
|
|
16246
16295
|
*/
|
|
16247
16296
|
async function applySkinCss(id) {
|
|
16248
|
-
const
|
|
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");
|
|
16297
|
+
const { css, source } = await fetchSkinCss(id);
|
|
16255
16298
|
let el = document.getElementById("folia-skin-hot");
|
|
16256
16299
|
if (!el) {
|
|
16257
16300
|
el = document.createElement("style");
|
|
16258
16301
|
el.id = "folia-skin-hot";
|
|
16259
|
-
// 挂 body 末尾(不是 head):文档顺序最后 ⇒ 同等特异性下覆盖先前的规则。
|
|
16260
16302
|
(document.body || document.head).appendChild(el);
|
|
16261
16303
|
}
|
|
16262
16304
|
el.textContent = css;
|
|
16263
16305
|
let rules = 0;
|
|
16264
16306
|
try { rules = (el.sheet && el.sheet.cssRules && el.sheet.cssRules.length) || 0; } catch { /* 读不到就报 0 */ }
|
|
16265
|
-
return { bytes: css.length, rules };
|
|
16307
|
+
return { bytes: css.length, rules, source };
|
|
16266
16308
|
}
|
|
16267
16309
|
|
|
16268
16310
|
/**
|
|
@@ -16320,12 +16362,13 @@ window.__ModuleLoader__.load({
|
|
|
16320
16362
|
}
|
|
16321
16363
|
el.src = pick.url;
|
|
16322
16364
|
// 皮肤 CSS 通过 `> img` / `> video` 选中它 ⇒ 必须是**直接子元素**。
|
|
16365
|
+
el.setAttribute("aria-hidden", "true");
|
|
16323
16366
|
el.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;";
|
|
16324
16367
|
layer.appendChild(el);
|
|
16325
16368
|
|
|
16326
16369
|
if (pick.scrim) {
|
|
16327
16370
|
const scrim = document.createElement("div");
|
|
16328
|
-
scrim.style.cssText = "position:absolute;inset:0;pointer-events:none;background
|
|
16371
|
+
scrim.style.cssText = "position:absolute;inset:0;pointer-events:none;background:" + pick.scrim + ";";
|
|
16329
16372
|
layer.appendChild(scrim);
|
|
16330
16373
|
}
|
|
16331
16374
|
|
|
@@ -16337,7 +16380,230 @@ window.__ModuleLoader__.load({
|
|
|
16337
16380
|
return { kind: pick.type };
|
|
16338
16381
|
}
|
|
16339
16382
|
|
|
16340
|
-
|
|
16383
|
+
/* ── 热切换运行时:作用域属性 + 六个装饰层 + hooks ───────────────────────────────
|
|
16384
|
+
*
|
|
16385
|
+
* 皮肤不是"一份 CSS"。它有三层,缺一层就"显示不全":
|
|
16386
|
+
* ① 样式:`stylesheet` + `patches`
|
|
16387
|
+
* ② 背景:`contributes.backgroundMedia`
|
|
16388
|
+
* ③ 作用域与装饰层:`html[data-dsh-skin="<id>"]` + 六个 `[data-dsh-skin-layer=...]`
|
|
16389
|
+
*
|
|
16390
|
+
* 第 ③ 项是"大部分皮肤看起来没生效"的真正原因 —— 65 套里有 **30 套**把规则整段挂在
|
|
16391
|
+
* `html[data-dsh-skin]` 下面,属性不设,那些规则一条都不匹配;另有 **28 套**带
|
|
16392
|
+
* `hooks.mjs`(粒子/交互),契约见皮肤中心 `contracts/hooks-api.d.ts`:
|
|
16393
|
+
* apply({ skinId, scopeAttr, assetBase, layers{...}, theme{get,subscribe}, onCleanup })
|
|
16394
|
+
* 所以这里补齐作用域属性、六个层,并按契约跑 hooks(失败只降级)。
|
|
16395
|
+
*/
|
|
16396
|
+
|
|
16397
|
+
const SKIN_LAYER_NAMES = ["background", "ambient", "bottom", "sidebar", "top", "foreground"];
|
|
16398
|
+
/**
|
|
16399
|
+
* 六层的**精确**样式:逐字对齐皮肤中心 `runtime/decoration-layers.ts` 的 `LAYER_STYLE`。
|
|
16400
|
+
*
|
|
16401
|
+
* 不能自己拍脑袋定层级 —— 它的注释写明了为什么:
|
|
16402
|
+
* · background 用 **-2**:负 z-index 元素画在 html/body 背景**之上**(皮肤自己的
|
|
16403
|
+
* 不透明底色因此在画后面、艺术图在它前面),但**在所有面板之下**;
|
|
16404
|
+
* · ambient 30、top/bottom/sidebar 40、foreground 41:压在面板之上、官方浮层
|
|
16405
|
+
* (>=1000)之下。之前我把非背景层留成 auto ⇒ 装饰条落进文档流最底层,
|
|
16406
|
+
* 表现正是"上面被面板盖住、只有下面露出背景"。
|
|
16407
|
+
*/
|
|
16408
|
+
const SKIN_LAYER_STYLE = {
|
|
16409
|
+
background: "position:fixed;top:0;right:0;bottom:0;left:0;z-index:-2;pointer-events:none;will-change:transform;",
|
|
16410
|
+
ambient: "position:fixed;top:0;right:0;bottom:0;left:0;z-index:30;pointer-events:none;",
|
|
16411
|
+
top: "position:fixed;top:0;left:0;right:0;z-index:40;pointer-events:none;",
|
|
16412
|
+
bottom: "position:fixed;bottom:0;left:0;right:0;z-index:40;pointer-events:none;",
|
|
16413
|
+
sidebar: "position:fixed;top:0;bottom:0;left:0;z-index:40;pointer-events:none;",
|
|
16414
|
+
foreground: "position:fixed;top:0;right:0;bottom:0;left:0;z-index:41;pointer-events:none;",
|
|
16415
|
+
};
|
|
16416
|
+
/** 本次激活注册的清理函数(切换皮肤时逐个跑掉,幂等)。 */
|
|
16417
|
+
let skinHotCleanups = [];
|
|
16418
|
+
|
|
16419
|
+
function skinThemeNow() {
|
|
16420
|
+
try {
|
|
16421
|
+
return (document.body && document.body.hasAttribute("data-ds-dark-theme")) ? "dark" : "light";
|
|
16422
|
+
} catch {
|
|
16423
|
+
return "light";
|
|
16424
|
+
}
|
|
16425
|
+
}
|
|
16426
|
+
|
|
16427
|
+
/** 建立/复用六个装饰层,返回契约里那个 `layers` 对象。 */
|
|
16428
|
+
function ensureSkinLayers() {
|
|
16429
|
+
const host = document.body || document.documentElement;
|
|
16430
|
+
const out = {};
|
|
16431
|
+
for (const name of SKIN_LAYER_NAMES) {
|
|
16432
|
+
let el = null;
|
|
16433
|
+
try { el = document.querySelector('[data-dsh-skin-layer="' + name + '"]'); } catch { el = null; }
|
|
16434
|
+
if (!el) {
|
|
16435
|
+
el = document.createElement("div");
|
|
16436
|
+
el.setAttribute("data-dsh-skin-layer", name);
|
|
16437
|
+
el.setAttribute("aria-hidden", "true");
|
|
16438
|
+
host.appendChild(el);
|
|
16439
|
+
}
|
|
16440
|
+
// 与它的 ensureOne 同口径:**每次都对已有层重写样式** —— 皮肤中心的层可能带着
|
|
16441
|
+
// 上一次激活留下的内联覆盖,只建不改会继承脏值。
|
|
16442
|
+
el.style.cssText = SKIN_LAYER_STYLE[name];
|
|
16443
|
+
out[name] = el;
|
|
16444
|
+
}
|
|
16445
|
+
// 皮肤中心自己会给 body 盖 `data-dsh-skin-center`(它的若干基础样式挂在这个前缀下),
|
|
16446
|
+
// 缺了它,面板/列表那些"表面透明"的规则不生效 → 大片面板仍然不透明。
|
|
16447
|
+
try { document.body.setAttribute("data-dsh-skin-center", ""); } catch { /* ignore */ }
|
|
16448
|
+
return out;
|
|
16449
|
+
}
|
|
16450
|
+
|
|
16451
|
+
/**
|
|
16452
|
+
* 按契约跑一套皮肤的 hooks。失败**只降级**(返回原因),绝不打断样式/背景。
|
|
16453
|
+
* @returns {Promise<{ ran: boolean, reason?: string }>}
|
|
16454
|
+
*/
|
|
16455
|
+
async function applySkinHooks(id) {
|
|
16456
|
+
const resp = await apiJson("/skins/manifest?id=" + encodeURIComponent(id));
|
|
16457
|
+
const doc = (resp && resp.data) || null;
|
|
16458
|
+
const rel = doc && doc.hooks;
|
|
16459
|
+
if (!rel) return { ran: false, reason: "no-hooks" };
|
|
16460
|
+
|
|
16461
|
+
const encoded = String(rel).split("/").map(encodeURIComponent).join("/");
|
|
16462
|
+
const r = await apiFetch("/skins/files/" + encodeURIComponent(id) + "/" + encoded, {
|
|
16463
|
+
parse: false,
|
|
16464
|
+
cache: "no-store",
|
|
16465
|
+
});
|
|
16466
|
+
if (!r || !r.ok || !r.response) return { ran: false, reason: "hooks-http-" + ((r && r.status) || 0) };
|
|
16467
|
+
const source = await r.response.text();
|
|
16468
|
+
if (!source || !source.trim()) return { ran: false, reason: "hooks-empty" };
|
|
16469
|
+
|
|
16470
|
+
const blobUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
|
16471
|
+
try {
|
|
16472
|
+
const mod = await import(blobUrl);
|
|
16473
|
+
const factory = mod && typeof mod.default === "function" ? mod.default : null;
|
|
16474
|
+
if (!factory) return { ran: false, reason: "no-default-export" };
|
|
16475
|
+
const hooks = factory();
|
|
16476
|
+
if (!hooks || typeof hooks.apply !== "function") return { ran: false, reason: "no-apply" };
|
|
16477
|
+
|
|
16478
|
+
const layers = ensureSkinLayers();
|
|
16479
|
+
const unsubs = [];
|
|
16480
|
+
const ctx = {
|
|
16481
|
+
skinId: id,
|
|
16482
|
+
scopeAttr: id,
|
|
16483
|
+
assetBase: apiUrl("/skins/files/" + encodeURIComponent(id) + "/"),
|
|
16484
|
+
layers,
|
|
16485
|
+
theme: {
|
|
16486
|
+
get: skinThemeNow,
|
|
16487
|
+
subscribe(listener) {
|
|
16488
|
+
try {
|
|
16489
|
+
const obs = new MutationObserver(() => {
|
|
16490
|
+
try { listener(skinThemeNow()); } catch { /* 监听方出错不影响别人 */ }
|
|
16491
|
+
});
|
|
16492
|
+
obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
|
|
16493
|
+
const off = () => { try { obs.disconnect(); } catch { /* ignore */ } };
|
|
16494
|
+
unsubs.push(off);
|
|
16495
|
+
return off;
|
|
16496
|
+
} catch {
|
|
16497
|
+
return () => {};
|
|
16498
|
+
}
|
|
16499
|
+
},
|
|
16500
|
+
},
|
|
16501
|
+
onCleanup(fn) { if (typeof fn === "function") skinHotCleanups.push(fn); },
|
|
16502
|
+
};
|
|
16503
|
+
hooks.apply(ctx);
|
|
16504
|
+
if (typeof hooks.dispose === "function") {
|
|
16505
|
+
skinHotCleanups.push(() => { try { hooks.dispose(); } catch { /* 幂等 */ } });
|
|
16506
|
+
}
|
|
16507
|
+
skinHotCleanups.push(() => { for (const off of unsubs) { try { off(); } catch { /* ignore */ } } });
|
|
16508
|
+
return { ran: true };
|
|
16509
|
+
} finally {
|
|
16510
|
+
setTimeout(() => { try { URL.revokeObjectURL(blobUrl); } catch { /* ignore */ } }, 10000);
|
|
16511
|
+
}
|
|
16512
|
+
}
|
|
16513
|
+
|
|
16514
|
+
/** 撤掉上一次热切换留下的东西(清理函数 + 注入样式)。作用域属性由下一次直接覆盖。 */
|
|
16515
|
+
function clearSkinHot() {
|
|
16516
|
+
for (const fn of skinHotCleanups.splice(0).reverse()) {
|
|
16517
|
+
try { fn(); } catch { /* 清理幂等,单个失败不影响其余 */ }
|
|
16518
|
+
}
|
|
16519
|
+
try {
|
|
16520
|
+
const el = document.getElementById("folia-skin-hot");
|
|
16521
|
+
if (el) el.remove();
|
|
16522
|
+
} catch { /* ignore */ }
|
|
16523
|
+
}
|
|
16524
|
+
|
|
16525
|
+
/**
|
|
16526
|
+
* 把一套皮肤**完整**应用上去(作用域 + 六层 + 样式 + 背景 + hooks)。
|
|
16527
|
+
* 按钮和"壁纸撤下后自动恢复"走的是**同一个**函数 —— 两条路径不能各写一遍。
|
|
16528
|
+
*/
|
|
16529
|
+
async function applySkinHot(id) {
|
|
16530
|
+
clearSkinHot();
|
|
16531
|
+
try { document.documentElement.setAttribute("data-dsh-skin", id); } catch { /* ignore */ }
|
|
16532
|
+
ensureSkinLayers();
|
|
16533
|
+
const info = await applySkinCss(id);
|
|
16534
|
+
const bg = await applySkinBackground(id);
|
|
16535
|
+
let hooksInfo = { ran: false, reason: "skipped" };
|
|
16536
|
+
try {
|
|
16537
|
+
hooksInfo = await applySkinHooks(id);
|
|
16538
|
+
} catch (e) {
|
|
16539
|
+
hooksInfo = { ran: false, reason: String((e && e.message) || e) };
|
|
16540
|
+
}
|
|
16541
|
+
return { info, bg, hooks: hooksInfo };
|
|
16542
|
+
}
|
|
16543
|
+
|
|
16544
|
+
/** 把皮肤**整个撤掉**,回到应用原生外观(壁纸接管背景时走这条)。 */
|
|
16545
|
+
function teardownSkin() {
|
|
16546
|
+
clearSkinHot();
|
|
16547
|
+
try { document.documentElement.removeAttribute("data-dsh-skin"); } catch { /* ignore */ }
|
|
16548
|
+
try { document.body.removeAttribute("data-dsh-skin-center"); } catch { /* ignore */ }
|
|
16549
|
+
for (const name of SKIN_LAYER_NAMES) {
|
|
16550
|
+
try {
|
|
16551
|
+
const el = document.querySelector('[data-dsh-skin-layer="' + name + '"]');
|
|
16552
|
+
if (el) el.innerHTML = "";
|
|
16553
|
+
} catch { /* ignore */ }
|
|
16554
|
+
}
|
|
16555
|
+
}
|
|
16556
|
+
|
|
16557
|
+
/** 向宿主问"当前选中的皮肤是哪个"(没选中返回空串)。 */
|
|
16558
|
+
async function activeSkinIdFromHost() {
|
|
16559
|
+
try {
|
|
16560
|
+
const resp = await apiJson("/skins");
|
|
16561
|
+
const doc = (resp && resp.data) || null;
|
|
16562
|
+
return (doc && typeof doc.active === "string") ? doc.active : "";
|
|
16563
|
+
} catch {
|
|
16564
|
+
return "";
|
|
16565
|
+
}
|
|
16566
|
+
}
|
|
16567
|
+
|
|
16568
|
+
let skinArbiterStarted = false;
|
|
16569
|
+
/**
|
|
16570
|
+
* **皮肤与壁纸互斥的仲裁器**(两个方向都要管)。
|
|
16571
|
+
*
|
|
16572
|
+
* 现状:点「使用」时我们会主动清掉壁纸(一个方向);但用户**反过来**点壁纸时,
|
|
16573
|
+
* 没人撤掉我们热注入的皮肤 —— 于是皮肤那层不透明样式压在壁纸上面,壁纸看起来被"遮挡"。
|
|
16574
|
+
*
|
|
16575
|
+
* 这里盯 `body[data-we-wallpaper]` 这个标记(**壁纸插件自己在挂/摘**,是双方共同的事实源):
|
|
16576
|
+
* · 标记出现 ⇒ 撤掉皮肤(teardownSkin)
|
|
16577
|
+
* · 标记消失 ⇒ 若选中过皮肤,重新完整应用(applySkinHot)
|
|
16578
|
+
* 这样无论用户先点哪边,最后都是"只有一个在台"。
|
|
16579
|
+
*/
|
|
16580
|
+
function startSkinArbiter() {
|
|
16581
|
+
if (skinArbiterStarted) return;
|
|
16582
|
+
const body = (typeof document !== "undefined") ? document.body : null;
|
|
16583
|
+
if (!body) { setTimeout(startSkinArbiter, 400); return; }
|
|
16584
|
+
skinArbiterStarted = true;
|
|
16585
|
+
let seq = 0;
|
|
16586
|
+
const sync = async () => {
|
|
16587
|
+
const mine = ++seq;
|
|
16588
|
+
const wallpaperOn = body.hasAttribute("data-we-wallpaper");
|
|
16589
|
+
if (wallpaperOn) { teardownSkin(); return; }
|
|
16590
|
+
const id = await activeSkinIdFromHost();
|
|
16591
|
+
if (mine !== seq) return; // 期间又变了,放弃这次
|
|
16592
|
+
if (!id) return;
|
|
16593
|
+
try { await applySkinHot(id); } catch { /* 失败就保持原生外观 */ }
|
|
16594
|
+
};
|
|
16595
|
+
try {
|
|
16596
|
+
new MutationObserver(() => { sync().catch(() => {}); })
|
|
16597
|
+
.observe(body, { attributes: true, attributeFilter: ["data-we-wallpaper"] });
|
|
16598
|
+
// 启动时就跑一次:处理"上次退出时壁纸在台、皮肤被让位"这类残留状态
|
|
16599
|
+
sync().catch(() => {});
|
|
16600
|
+
} catch { /* 环境不支持观察就只保留按钮路径 */ }
|
|
16601
|
+
}
|
|
16602
|
+
|
|
16603
|
+
function SkinsTabPanel(props) {
|
|
16604
|
+
// 壁纸让位:挂载点(设置页 / 首页抽屉)各自把"清掉当前壁纸"的回调传进来 ——
|
|
16605
|
+
// 皮肤和壁纸都在铺背景,必须二选一。本组件不直接碰设置,只调用传进来的入口。
|
|
16606
|
+
const onClearWallpaper = props && props.onClearWallpaper;
|
|
16341
16607
|
const [list, setList] = React.useState([]);
|
|
16342
16608
|
const [active, setActive] = React.useState(null);
|
|
16343
16609
|
const [loading, setLoading] = React.useState(true);
|
|
@@ -16399,18 +16665,23 @@ window.__ModuleLoader__.load({
|
|
|
16399
16665
|
setNote({ kind: "ok", text: weT("已安装:") + label });
|
|
16400
16666
|
await load(true);
|
|
16401
16667
|
} else if (kind === "activate") {
|
|
16402
|
-
//
|
|
16403
|
-
|
|
16404
|
-
// 这一行就是判断"是没注入"还是"注入了但被压住"的依据,不用再猜。
|
|
16668
|
+
// **当场换肤**:作用域 + 六层 + 样式 + 背景 + hooks,全在 applySkinHot 里
|
|
16669
|
+
//(与"壁纸撤下后自动恢复"共用同一实现,两条路径不会漂移)。
|
|
16405
16670
|
let info = null;
|
|
16406
16671
|
let applyErr = "";
|
|
16407
16672
|
let bgKind = "none";
|
|
16673
|
+
let hooksInfo = { ran: false, reason: "skipped" };
|
|
16408
16674
|
try {
|
|
16409
|
-
|
|
16410
|
-
//
|
|
16411
|
-
|
|
16412
|
-
|
|
16413
|
-
|
|
16675
|
+
// ① 壁纸先让位:清掉 `id` 之后壁纸层撤下、`data-we-wallpaper` 消失,
|
|
16676
|
+
// 皮肤中心也不会再扣住皮肤。(反方向由 startSkinArbiter 负责。)
|
|
16677
|
+
if (typeof onClearWallpaper === "function") {
|
|
16678
|
+
try { await Promise.resolve(onClearWallpaper()); } catch { /* 清不掉也继续换肤 */ }
|
|
16679
|
+
}
|
|
16680
|
+
// ② 应用皮肤
|
|
16681
|
+
const applied = await applySkinHot(id);
|
|
16682
|
+
info = applied.info;
|
|
16683
|
+
bgKind = applied.bg.kind;
|
|
16684
|
+
hooksInfo = applied.hooks;
|
|
16414
16685
|
} catch (e) {
|
|
16415
16686
|
applyErr = String((e && e.message) || e);
|
|
16416
16687
|
}
|
|
@@ -16420,8 +16691,11 @@ window.__ModuleLoader__.load({
|
|
|
16420
16691
|
? weT("已切换为「") + label + weT("」,但样式注入失败:") + applyErr
|
|
16421
16692
|
: weT("已切换到「") + label + weT("」—— 样式 ")
|
|
16422
16693
|
+ Math.round(info.bytes / 1024) + " KB / " + info.rules + weT(" 条规则")
|
|
16423
|
-
+ (
|
|
16424
|
-
|
|
16694
|
+
+ (info.source === "skin-center" ? weT("(已作用域化)") : weT("(未作用域化,可能显示不全)"))
|
|
16695
|
+
+ (bgKind === "none" ? weT(",无背景媒体") : weT(",背景:") + bgKind)
|
|
16696
|
+
+ (hooksInfo.ran ? weT(",动效已启动") : (hooksInfo.reason === "no-hooks" ? weT(",无动效") : weT(",动效未启动(") + hooksInfo.reason + ")"))
|
|
16697
|
+
+ (typeof onClearWallpaper === "function" ? weT(",壁纸已关闭") : ""),
|
|
16698
|
+
// hooks 已经跑过了;这里只作为"整页重载"的可选入口(极少数皮肤依赖启动期布局)
|
|
16425
16699
|
reload: true,
|
|
16426
16700
|
});
|
|
16427
16701
|
// 不再整表重拉:本地标记已经对了,省掉那 600 ms 的"没反应"窗口。
|
|
@@ -16636,11 +16910,20 @@ window.__ModuleLoader__.load({
|
|
|
16636
16910
|
return React.createElement(React.Fragment, null, children);
|
|
16637
16911
|
}
|
|
16638
16912
|
|
|
16639
|
-
/**
|
|
16640
|
-
|
|
16641
|
-
|
|
16913
|
+
/**
|
|
16914
|
+
* 页签薄包装:`renderActiveTab` 只认这个签名(与别的 `render*Tab(ctx)` 一致)。
|
|
16915
|
+
* 传进来的 `props` 原样转交给组件 —— 目前只有 `onClearWallpaper`(皮肤与壁纸互斥,
|
|
16916
|
+
* 由挂载点提供"清掉当前壁纸"的入口;本文件不直接碰设置)。
|
|
16917
|
+
*/
|
|
16918
|
+
function renderSkinsTab(props) {
|
|
16919
|
+
return React.createElement(SkinsTabPanel, props || null);
|
|
16642
16920
|
}
|
|
16643
16921
|
|
|
16922
|
+
// 模块加载即启动互斥仲裁(不依赖用户是否打开过皮肤包页签):
|
|
16923
|
+
// 壁纸在台 ⇒ 撤掉皮肤;壁纸撤下 ⇒ 恢复选中的皮肤。
|
|
16924
|
+
// 放在模块作用域而不是 React effect 里,是因为它必须在**任何面板渲染之前**就守住这个不变量。
|
|
16925
|
+
try { startSkinArbiter(); } catch { /* 环境异常时退化为纯按钮路径 */ }
|
|
16926
|
+
|
|
16644
16927
|
// ── 内联模块:src/picker-model.js —— 选择器模型层:过滤判定 + 派生数据 + 分页切片(纯函数 / 显式入参 / 零 DOM)(构建期注入;勿手改本段)──
|
|
16645
16928
|
/**
|
|
16646
16929
|
* picker-model.js — 壁纸选择器的**模型层**:过滤判定 + 派生数据 + 分页切片。
|
|
@@ -18247,7 +18530,7 @@ window.__ModuleLoader__.load({
|
|
|
18247
18530
|
? qpRenderPlaybackPane(sel, switchQpTab)
|
|
18248
18531
|
// 皮肤包:直接挂设置页那个同一个组件(它自己管列表/下载/激活状态)。
|
|
18249
18532
|
: qpTab === "skins"
|
|
18250
|
-
? React.createElement(SkinsTabPanel,
|
|
18533
|
+
? React.createElement(SkinsTabPanel, { onClearWallpaper: () => setSetting("id", "") })
|
|
18251
18534
|
: null),
|
|
18252
18535
|
!(qpTab === "wallpaper" && userPropsPanelOpen() && propsAvailable) && qpTab === "wallpaper" && React.createElement(React.Fragment, null,
|
|
18253
18536
|
// ── 壁纸列表(搜索 + 视图切换 + 快切)──
|
|
@@ -22614,6 +22897,34 @@ window.__ModuleLoader__.load({
|
|
|
22614
22897
|
/** DELETE(多数接口不返回体;需要时传 parse: true)。 */
|
|
22615
22898
|
const apiDelete = (path, options) => apiFetch(path, Object.assign({ method: 'DELETE', parse: false }, options));
|
|
22616
22899
|
|
|
22900
|
+
/**
|
|
22901
|
+
* 根相对路径的**原生**请求(**不补 BASE**),返回原始 `Response`(失败给 null)。
|
|
22902
|
+
*
|
|
22903
|
+
* 用途:访问**同源其它插件**的路由 —— 典型是皮肤中心的 `/api/skin-center/v2/...`
|
|
22904
|
+
*(它把皮肤 CSS 做了强制作用域化,只有那条路由给的版本才能压过应用自身规则)。
|
|
22905
|
+
*
|
|
22906
|
+
* 为什么必须是**根相对路径**而不是 `location.origin + path`:桌面壳里文档可能跑在
|
|
22907
|
+
* 自定义协议下(`dsh-app://…`),此时 `location.origin` 是 `"null"`,拼出来的绝对 URL
|
|
22908
|
+
* 直接作废。`/api/...` 交给浏览器按文档解析,两种协议下都成立 —— 皮肤中心自己的客户端
|
|
22909
|
+
* 用的也正是这种写法。
|
|
22910
|
+
*
|
|
22911
|
+
* @param {string} path 以 `/` 开头的同源路径
|
|
22912
|
+
* @param {{ method?: string, cache?: string, headers?: object, fetch?: Function }} [options]
|
|
22913
|
+
* @returns {Promise<Response|null>}
|
|
22914
|
+
*/
|
|
22915
|
+
async function apiRawResponse(path, options) {
|
|
22916
|
+
const o = options || {};
|
|
22917
|
+
const doFetch = pickFetch(o.fetch);
|
|
22918
|
+
if (!doFetch) return null;
|
|
22919
|
+
const init = { method: o.method || 'GET', cache: o.cache || 'no-store' };
|
|
22920
|
+
if (o.headers) init.headers = o.headers;
|
|
22921
|
+
try {
|
|
22922
|
+
return await doFetch(String(path), init);
|
|
22923
|
+
} catch {
|
|
22924
|
+
return null;
|
|
22925
|
+
}
|
|
22926
|
+
}
|
|
22927
|
+
|
|
22617
22928
|
// ── 内联模块:src/occlusion.js —— 遮挡投递:/occlusion 轮询 + 缩放 + postMessage(1Hz,含退避与可见性暂停)(构建期注入;勿手改本段)──
|
|
22618
22929
|
/**
|
|
22619
22930
|
* occlusion.js — Linux 壳窗遮挡的**客户端轮询器**:宿主 `/occlusion` 快照 → 画布局部
|
|
@@ -29865,7 +30176,7 @@ window.__ModuleLoader__.load({
|
|
|
29865
30176
|
if (activeTab === "extensions") return renderExtensionsTab(extensionCtx());
|
|
29866
30177
|
// 「皮肤包」自带异步状态(列表 / 下载中 / 错误),所以它是一个**真组件**而不是
|
|
29867
30178
|
// 纯渲染函数 —— 薄包装只负责挂上去(理由见 src/panel-tabs-skins.js 文件头)。
|
|
29868
|
-
if (activeTab === "skins") return renderSkinsTab();
|
|
30179
|
+
if (activeTab === "skins") return renderSkinsTab({ onClearWallpaper: () => setSetting("id", "") });
|
|
29869
30180
|
if (activeTab === "appearance") return renderAppearanceTab({
|
|
29870
30181
|
setSetting, setTransient,
|
|
29871
30182
|
fontSet: fontSetCtx(),
|
package/lib/routes/skins.js
CHANGED
|
@@ -646,6 +646,103 @@ 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
|
+
|
|
683
|
+
/* ── 皮肤中心「加工后 CSS」的宿主端代理 ──────────────────────────────────────
|
|
684
|
+
* 皮肤中心的 `/skins/<id>/stylesheet|patches` 是 `transformSkinCss()` 的产物:
|
|
685
|
+
* **每条规则被强制包进 `html[data-dsh-skin="<id>"]`**,特异性高于应用自身规则 ——
|
|
686
|
+
* 只有那份 CSS 才能真正把面板压透。皮肤目录里的原始 CSS 压不住,于是"显示不全"。
|
|
687
|
+
*
|
|
688
|
+
* 为什么需要代理:浏览器直接请求 `/api/skin-center/v2/...` 被 DSH 网关挡掉
|
|
689
|
+
*(实测前端一直落到"未作用域化"的降级分支)。宿主端在本机回环上再发一次、并**带上
|
|
690
|
+
* 浏览器这次请求的 Cookie 与同源头**,就能拿到那份产物。
|
|
691
|
+
*
|
|
692
|
+
* 拿不到(皮肤中心没装 / 该皮肤不在它的 catalog 里)一律 404,客户端据此回退到原始 CSS。
|
|
693
|
+
*/
|
|
694
|
+
disposers.push(webServer.register({
|
|
695
|
+
kind: 'exact',
|
|
696
|
+
path: `${base}/skins/scoped-css`,
|
|
697
|
+
handler: async (req, res) => {
|
|
698
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
699
|
+
const id = url.searchParams.get('id') || '';
|
|
700
|
+
if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
|
|
701
|
+
const host = req && req.headers && req.headers.host;
|
|
702
|
+
if (!host) { res.statusCode = 500; res.end(); return; }
|
|
703
|
+
const origin = 'http://' + host;
|
|
704
|
+
// 透传**全部**入站头(网关认的是这次会话的凭证,可能是 cookie,也可能是某个自定义
|
|
705
|
+
// 头;只挑几个字段必漏),再去掉逐跳头并补上同源语义。
|
|
706
|
+
// 实测:裸请求与"只带 sec-fetch-*"都回 401 ⇒ 凭证不在这几个头里。
|
|
707
|
+
const HOP_BY_HOP = new Set([
|
|
708
|
+
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te', 'trailer',
|
|
709
|
+
'proxy-authorization', 'proxy-authenticate', 'content-length', 'accept-encoding',
|
|
710
|
+
]);
|
|
711
|
+
const fwdHeaders = {};
|
|
712
|
+
for (const [k, v] of Object.entries(req.headers || {})) {
|
|
713
|
+
const key = k.toLowerCase();
|
|
714
|
+
if (HOP_BY_HOP.has(key)) continue;
|
|
715
|
+
if (typeof v === 'string') fwdHeaders[key] = v;
|
|
716
|
+
else if (Array.isArray(v)) fwdHeaders[key] = v.join(', ');
|
|
717
|
+
}
|
|
718
|
+
fwdHeaders.origin = origin;
|
|
719
|
+
fwdHeaders.referer = origin + '/';
|
|
720
|
+
fwdHeaders['sec-fetch-site'] = 'same-origin';
|
|
721
|
+
fwdHeaders['sec-fetch-mode'] = 'cors';
|
|
722
|
+
fwdHeaders['sec-fetch-dest'] = 'empty';
|
|
723
|
+
fwdHeaders.accept = 'text/css,*/*;q=0.1';
|
|
724
|
+
|
|
725
|
+
for (const part of ['stylesheet', 'patches']) {
|
|
726
|
+
try {
|
|
727
|
+
const r = await fetch(origin + '/api/skin-center/v2/skins/' + encodeURIComponent(id) + '/' + part, {
|
|
728
|
+
headers: fwdHeaders,
|
|
729
|
+
signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
|
|
730
|
+
});
|
|
731
|
+
if (!r.ok) continue;
|
|
732
|
+
const text = await r.text();
|
|
733
|
+
if (!text || !text.trim()) continue;
|
|
734
|
+
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
735
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
736
|
+
res.setHeader('X-Folia-Scoped', '1');
|
|
737
|
+
res.end(text);
|
|
738
|
+
return;
|
|
739
|
+
} catch { /* 这一段失败就试下一段 */ }
|
|
740
|
+
}
|
|
741
|
+
res.statusCode = 404;
|
|
742
|
+
res.end();
|
|
743
|
+
},
|
|
744
|
+
}));
|
|
745
|
+
|
|
649
746
|
disposers.push(webServer.register({
|
|
650
747
|
kind: 'exact',
|
|
651
748
|
path: `${base}/skins/asset`,
|