dsh-folia 2.4.5 → 2.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 +63 -18
- package/lib/routes/skins.js +130 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -16230,6 +16230,33 @@ window.__ModuleLoader__.load({
|
|
|
16230
16230
|
return code;
|
|
16231
16231
|
}
|
|
16232
16232
|
|
|
16233
|
+
/**
|
|
16234
|
+
* 把一套皮肤的样式**当场**挂进文档:`<link id="folia-skin-hot">` 追加到 `<head>` 末尾。
|
|
16235
|
+
*
|
|
16236
|
+
* 为什么不用"刷新页面":皮肤中心只在页面加载时应用皮肤,而桌面壳里 `location.reload()`
|
|
16237
|
+
* 常常被吞掉(用户看到的就是"点了没反应")。直接注入 CSS 不依赖它 —— 后加载的规则
|
|
16238
|
+
* 覆盖先前的,于是不刷新也能换肤。代价:皮肤自带的 `hooks.mjs`(粒子等动效)不会跑,
|
|
16239
|
+
* 那部分仍然只有整页重载/重启才会生效;这一点在下方的提示里如实写明。
|
|
16240
|
+
*
|
|
16241
|
+
* @param {string} id 皮肤 id
|
|
16242
|
+
*/
|
|
16243
|
+
function applySkinCss(id) {
|
|
16244
|
+
try {
|
|
16245
|
+
const href = apiUrl("/skins/css?id=" + encodeURIComponent(id) + "&t=" + Date.now());
|
|
16246
|
+
let el = document.getElementById("folia-skin-hot");
|
|
16247
|
+
if (!el) {
|
|
16248
|
+
el = document.createElement("link");
|
|
16249
|
+
el.id = "folia-skin-hot";
|
|
16250
|
+
el.rel = "stylesheet";
|
|
16251
|
+
document.head.appendChild(el);
|
|
16252
|
+
}
|
|
16253
|
+
el.href = href; // 带时间戳:绕过缓存,换皮肤立刻拿到新样式
|
|
16254
|
+
return true;
|
|
16255
|
+
} catch {
|
|
16256
|
+
return false;
|
|
16257
|
+
}
|
|
16258
|
+
}
|
|
16259
|
+
|
|
16233
16260
|
function SkinsTabPanel() {
|
|
16234
16261
|
const [list, setList] = React.useState([]);
|
|
16235
16262
|
const [active, setActive] = React.useState(null);
|
|
@@ -16292,25 +16319,17 @@ window.__ModuleLoader__.load({
|
|
|
16292
16319
|
setNote({ kind: "ok", text: weT("已安装:") + label });
|
|
16293
16320
|
await load(true);
|
|
16294
16321
|
} else if (kind === "activate") {
|
|
16322
|
+
// **当场换肤**:把皮肤样式直接注入文档,不刷新、不重启。
|
|
16323
|
+
// (背景:皮肤中心只在页面加载时应用皮肤,而桌面壳会吞掉 location.reload。)
|
|
16324
|
+
const applied = applySkinCss(id);
|
|
16295
16325
|
setNote({
|
|
16296
16326
|
kind: "ok",
|
|
16297
|
-
text:
|
|
16298
|
-
|
|
16299
|
-
|
|
16327
|
+
text: applied
|
|
16328
|
+
? weT("已切换到「") + label + weT("」—— 样式已即时应用")
|
|
16329
|
+
: weT("已切换为「") + label + weT("」,但样式注入失败,请重启 DSH"),
|
|
16330
|
+
// hooks(粒子等动效)只有整页重载才会跑:给一个可选入口,不强制。
|
|
16300
16331
|
reload: true,
|
|
16301
16332
|
});
|
|
16302
|
-
// 一次点击就完成:这里自动重新加载,不再让用户点第二次。
|
|
16303
|
-
// 用 `location.replace(href)` 而非 `reload()`:桌面壳里 reload 有时被吞掉
|
|
16304
|
-
//(表现正是"点了没反应"),硬跳转更容易生效,且 replace 不往历史里塞条目。
|
|
16305
|
-
try {
|
|
16306
|
-
setTimeout(function tryApply() {
|
|
16307
|
-
try {
|
|
16308
|
-
window.location.replace(window.location.href);
|
|
16309
|
-
} catch (e1) {
|
|
16310
|
-
try { window.location.reload(); } catch (e2) { /* 交给手动按钮 */ }
|
|
16311
|
-
}
|
|
16312
|
-
}, 700);
|
|
16313
|
-
} catch { /* 环境不支持 → 手动按钮兜底 */ }
|
|
16314
16333
|
// 不再整表重拉:本地标记已经对了,省掉那 600 ms 的"没反应"窗口。
|
|
16315
16334
|
} else if (kind === "remove") {
|
|
16316
16335
|
setNote({ kind: "ok", text: weT("已删除:") + label });
|
|
@@ -16380,10 +16399,36 @@ window.__ModuleLoader__.load({
|
|
|
16380
16399
|
React.createElement("span", null, weT("当前皮肤:") + activeName),
|
|
16381
16400
|
React.createElement("span", {
|
|
16382
16401
|
style: { fontSize: "11px", padding: "1px 6px", borderRadius: "999px", background: "rgba(142,230,168,0.16)", color: "#8ee6a8" },
|
|
16383
|
-
}, weT("
|
|
16402
|
+
}, weT("已应用")),
|
|
16384
16403
|
));
|
|
16385
16404
|
}
|
|
16386
16405
|
|
|
16406
|
+
// ⚠️ **壁纸在台时皮肤不会生效** —— 这是皮肤中心写死的行为,不是我们的 bug:
|
|
16407
|
+
// · host 侧:peer(= 本插件)持久化的壁纸选择非空时,它从文档里**扣住**皮肤;
|
|
16408
|
+
// · client 侧:它盯 `body[data-we-wallpaper]`,只要那个标记在,就把皮肤让位
|
|
16409
|
+
// (源码注释:`an active WE wallpaper suppresses skin media`)。
|
|
16410
|
+
// 于是"点了使用但界面没变"最可能的原因就是**壁纸还开着**。这里直接读那个标记,
|
|
16411
|
+
// 命中就把话说清楚,并告诉用户该先做什么。
|
|
16412
|
+
let wallpaperOn = false;
|
|
16413
|
+
try {
|
|
16414
|
+
wallpaperOn = Boolean(document.body && document.body.hasAttribute("data-we-wallpaper"));
|
|
16415
|
+
} catch { /* 拿不到 DOM 就当没有 */ }
|
|
16416
|
+
if (wallpaperOn) {
|
|
16417
|
+
children.push(React.createElement("div", {
|
|
16418
|
+
key: "wpwarning",
|
|
16419
|
+
className: "we-picker__hint",
|
|
16420
|
+
style: {
|
|
16421
|
+
margin: "4px 0 10px",
|
|
16422
|
+
padding: "7px 10px",
|
|
16423
|
+
borderRadius: "8px",
|
|
16424
|
+
background: "rgba(255,196,120,0.12)",
|
|
16425
|
+
color: "#ffd08a",
|
|
16426
|
+
lineHeight: 1.5,
|
|
16427
|
+
},
|
|
16428
|
+
}, weT("⚠️ 当前有壁纸在台:皮肤会被壁纸让位,切换不会生效。")
|
|
16429
|
+
+ weT(" 先到「壁纸」页签点「清除」,再回来切换皮肤。")));
|
|
16430
|
+
}
|
|
16431
|
+
|
|
16387
16432
|
if (error) {
|
|
16388
16433
|
children.push(React.createElement("div", {
|
|
16389
16434
|
key: "err",
|
|
@@ -16417,8 +16462,8 @@ window.__ModuleLoader__.load({
|
|
|
16417
16462
|
try { window.location.reload(); } catch { /* 极端环境刷不动就让用户手动重启 */ }
|
|
16418
16463
|
}
|
|
16419
16464
|
},
|
|
16420
|
-
title: weT("
|
|
16421
|
-
}, weT("
|
|
16465
|
+
title: weT("整页重载 —— 皮肤动效(粒子等)需要重载才跑;纯配色已即时生效"),
|
|
16466
|
+
}, weT("重载以播放动效"))
|
|
16422
16467
|
: null,
|
|
16423
16468
|
));
|
|
16424
16469
|
}
|
package/lib/routes/skins.js
CHANGED
|
@@ -489,6 +489,136 @@ export function registerSkinRoutes(webServer, c) {
|
|
|
489
489
|
},
|
|
490
490
|
}));
|
|
491
491
|
|
|
492
|
+
/* ── 热切换:把皮肤样式自己喂给浏览器 ──────────────────────────────────────
|
|
493
|
+
* 皮肤中心只在**页面加载时**应用皮肤(它的 host 侧把样式塞进文档、client 侧启动时拉
|
|
494
|
+
* `/api/skin-center/v2/active`),桌面壳里 `location.reload()` 又常常被吞掉 ⇒
|
|
495
|
+
* 用户点「使用」后界面永远不变。
|
|
496
|
+
*
|
|
497
|
+
* 所以这里补两条只读路由,把 `harness/skins/<id>/` 里的样式与素材直接发给页面:
|
|
498
|
+
* GET /skins/css?id=<id> → skin.css(`url()` 已重写成下面那条素材路由)
|
|
499
|
+
* GET /skins/asset?id=<id>&p=<rel> → 皮肤目录里的任意素材(图片/字体)
|
|
500
|
+
* 客户端把 CSS 挂成一个 `<link id="folia-skin-hot">` 追加在 `<head>` 末尾 ——
|
|
501
|
+
* 后加载的规则覆盖先前的,于是**不刷新也能换肤**;hooks(粒子等)不会跑,那部分
|
|
502
|
+
* 仍归皮肤中心管。
|
|
503
|
+
*/
|
|
504
|
+
|
|
505
|
+
/** 读一套皮肤的 manifest,拿 `contributes.stylesheet`。 */
|
|
506
|
+
function skinStylesheetOf(dir) {
|
|
507
|
+
try {
|
|
508
|
+
const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
|
|
509
|
+
const rel = doc && doc.contributes && doc.contributes.stylesheet;
|
|
510
|
+
if (typeof rel !== 'string') return null;
|
|
511
|
+
const segs = safeRelPath(rel);
|
|
512
|
+
if (!segs || segs.includes('..')) return null;
|
|
513
|
+
return { rel: segs.join('/'), doc };
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 取 manifests 里某个可选的样式类字段(当前用于 `patches`)。
|
|
521
|
+
* 与 stylesheet 同样的安全校验:只接受皮肤目录内的相对路径。
|
|
522
|
+
*/
|
|
523
|
+
function skinAuxFileOf(dir, key) {
|
|
524
|
+
try {
|
|
525
|
+
const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
|
|
526
|
+
const rel = doc && doc.contributes && doc.contributes[key];
|
|
527
|
+
if (typeof rel !== 'string' || !rel) return null;
|
|
528
|
+
const segs = safeRelPath(rel);
|
|
529
|
+
if (!segs || segs.includes('..')) return null;
|
|
530
|
+
return segs.join('/');
|
|
531
|
+
} catch {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const ASSET_TYPES = {
|
|
537
|
+
'.css': 'text/css; charset=utf-8',
|
|
538
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
539
|
+
'.json': 'application/json; charset=utf-8',
|
|
540
|
+
'.svg': 'image/svg+xml',
|
|
541
|
+
'.png': 'image/png',
|
|
542
|
+
'.jpg': 'image/jpeg',
|
|
543
|
+
'.jpeg': 'image/jpeg',
|
|
544
|
+
'.webp': 'image/webp',
|
|
545
|
+
'.gif': 'image/gif',
|
|
546
|
+
'.avif': 'image/avif',
|
|
547
|
+
'.woff': 'font/woff',
|
|
548
|
+
'.woff2': 'font/woff2',
|
|
549
|
+
'.ttf': 'font/ttf',
|
|
550
|
+
'.otf': 'font/otf',
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
disposers.push(webServer.register({
|
|
554
|
+
kind: 'exact',
|
|
555
|
+
path: `${base}/skins/css`,
|
|
556
|
+
handler: async (req, res) => {
|
|
557
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
558
|
+
const id = url.searchParams.get('id') || '';
|
|
559
|
+
if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
|
|
560
|
+
const harness = harnessRoot();
|
|
561
|
+
if (!harness) { res.statusCode = 503; res.end(); return; }
|
|
562
|
+
const dir = join(harness, SKINS_DIR_NAME, id);
|
|
563
|
+
const sheet = skinStylesheetOf(dir);
|
|
564
|
+
if (!sheet) { res.statusCode = 404; res.end(); return; }
|
|
565
|
+
let css;
|
|
566
|
+
try {
|
|
567
|
+
// ⚠️ 一套皮肤通常有**两份样式**:`contributes.stylesheet`(token 与结构)与
|
|
568
|
+
// `contributes.patches`(覆盖补丁)。**很多皮肤把全部改动写在 patches 里,
|
|
569
|
+
// stylesheet 故意留空**(例如 black-gold-vip 的 skin.css 只有一行注释)。
|
|
570
|
+
// 只下发 stylesheet ⇒ 换肤"看起来毫无变化"。两份都读,patches 放在后面覆盖。
|
|
571
|
+
const files = [];
|
|
572
|
+
files.push(sheet.rel);
|
|
573
|
+
const patchesRel = skinAuxFileOf(dir, 'patches');
|
|
574
|
+
if (patchesRel) files.push(patchesRel);
|
|
575
|
+
css = files.map((rel) => {
|
|
576
|
+
try { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); } catch { return ''; }
|
|
577
|
+
}).join('\n/* ---- patches ---- */\n');
|
|
578
|
+
} catch {
|
|
579
|
+
res.statusCode = 404;
|
|
580
|
+
res.end();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// `url(...)` 全部改写成素材路由:CSS 是从 `/folia/skins/css` 下发的,相对路径会
|
|
584
|
+
// 解析到错误的前缀;改成绝对路径后,图片/字体才找得到。
|
|
585
|
+
const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
|
|
586
|
+
const abs = (p) => {
|
|
587
|
+
if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
|
|
588
|
+
const rel = (baseDir ? baseDir + '/' : '') + p;
|
|
589
|
+
return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
|
|
590
|
+
};
|
|
591
|
+
css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${abs(p.trim())}")`);
|
|
592
|
+
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
593
|
+
// 不缓存:换皮肤后必须立刻拿到新的一份,否则用户会看到上一套的样式。
|
|
594
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
595
|
+
res.end(css);
|
|
596
|
+
},
|
|
597
|
+
}));
|
|
598
|
+
|
|
599
|
+
disposers.push(webServer.register({
|
|
600
|
+
kind: 'exact',
|
|
601
|
+
path: `${base}/skins/asset`,
|
|
602
|
+
handler: async (req, res) => {
|
|
603
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
604
|
+
const id = url.searchParams.get('id') || '';
|
|
605
|
+
const rel = url.searchParams.get('p') || '';
|
|
606
|
+
if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
|
|
607
|
+
const segs = safeRelPath(rel);
|
|
608
|
+
if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
|
|
609
|
+
const harness = harnessRoot();
|
|
610
|
+
if (!harness) { res.statusCode = 503; res.end(); return; }
|
|
611
|
+
const root = join(harness, SKINS_DIR_NAME, id);
|
|
612
|
+
const file = join(root, ...segs);
|
|
613
|
+
if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
|
|
614
|
+
if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
|
|
615
|
+
const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
|
|
616
|
+
res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
|
|
617
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
618
|
+
res.end(readFileSync(file));
|
|
619
|
+
},
|
|
620
|
+
}));
|
|
621
|
+
|
|
492
622
|
disposers.push(webServer.register({
|
|
493
623
|
kind: 'exact',
|
|
494
624
|
path: `${base}/skins/preview`,
|