dsh-folia 2.9.1 → 3.0.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
@@ -16249,6 +16249,24 @@ window.__ModuleLoader__.load({
16249
16249
  const enc = encodeURIComponent(id);
16250
16250
  const parts = [];
16251
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()) {
16260
+ // 响应头说明这份是谁作用域化的:
16261
+ // skin-center = 拿到了皮肤中心加工的那份;local = 宿主自己做的(等价结果)
16262
+ let via = "local";
16263
+ try { via = viaHost.response.headers.get("x-folia-scoped") || "local"; } catch { /* 读不到按 local */ }
16264
+ return { css: text, source: via === "skin-center" ? "skin-center" : "local-scoped" };
16265
+ }
16266
+ }
16267
+ } catch { /* 代理不可用 ⇒ 继续走下面两条 */ }
16268
+
16269
+ // ② 前端直连(皮肤中心同源路由,能通的话最省一次往返)
16252
16270
  for (const part of ["stylesheet", "patches"]) {
16253
16271
  try {
16254
16272
  // 根相对路径 + 原生请求:桌面壳可能跑在 `dsh-app://` 下(`location.origin` 是 "null"),
@@ -16679,7 +16697,7 @@ window.__ModuleLoader__.load({
16679
16697
  ? weT("已切换为「") + label + weT("」,但样式注入失败:") + applyErr
16680
16698
  : weT("已切换到「") + label + weT("」—— 样式 ")
16681
16699
  + Math.round(info.bytes / 1024) + " KB / " + info.rules + weT(" 条规则")
16682
- + (info.source === "skin-center" ? weT("(已作用域化)") : weT("(未作用域化,可能显示不全)"))
16700
+ + ((info.source === "skin-center" || info.source === "local-scoped") ? weT("(已作用域化)") : weT("(未作用域化,可能显示不全)"))
16683
16701
  + (bgKind === "none" ? weT(",无背景媒体") : weT(",背景:") + bgKind)
16684
16702
  + (hooksInfo.ran ? weT(",动效已启动") : (hooksInfo.reason === "no-hooks" ? weT(",无动效") : weT(",动效未启动(") + hooksInfo.reason + ")"))
16685
16703
  + (typeof onClearWallpaper === "function" ? weT(",壁纸已关闭") : ""),
@@ -680,6 +680,216 @@ export function registerSkinRoutes(webServer, c) {
680
680
  },
681
681
  }));
682
682
 
683
+ /* ── 自己实现「作用域化」,不再依赖皮肤中心的接口 ────────────────────────────
684
+ *
685
+ * 皮肤中心的 `/api/skin-center/v2/skins/<id>/stylesheet` 会走 `transformSkinCss()`:
686
+ * 把**每条选择器**包进 `html[data-dsh-skin="<id>"]`。这一步决定皮肤能不能压住应用
687
+ * 自身的面板/令牌规则 —— 作用域化之后特异性更高。
688
+ *
689
+ * 但那条路由在网关后面:浏览器直连 401,宿主代发(哪怕透传全部请求头)也可能 401。
690
+ * 而"让每个用户把自己的会话凭证交给插件"不是可交付的方案。所以这里**自己算**:
691
+ * 读皮肤目录里的 CSS,做同一件事,输出等价结果。
692
+ *
693
+ * 转换规则:
694
+ * · 普通规则:`a, b` → `html[data-dsh-skin="x"] a, html[data-dsh-skin="x"] b`
695
+ * · 以 `:root` 开头的:开头换成作用域本身
696
+ * · 以 `html` 开头的:原样保留(作者自己已作用域化,别套两层)
697
+ * · `@media/@supports/@layer/@container`:**递归进去**处理内部规则
698
+ * · `@keyframes/@font-face/@page/@property/@import`:原样保留
699
+ * (关键帧内部是 `0% {}` 这种选择器,套作用域会直接毁掉动画)
700
+ */
701
+
702
+ /** 从 `openIdx`(指向 `{`)找配对的 `}`,跳过注释与字符串。找不到返回 -1。 */
703
+ function matchBrace(css, openIdx) {
704
+ let depth = 0;
705
+ for (let i = openIdx; i < css.length; i++) {
706
+ const ch = css[i];
707
+ if (ch === '/' && css[i + 1] === '*') {
708
+ const end = css.indexOf('*/', i + 2);
709
+ if (end < 0) return -1;
710
+ i = end + 1;
711
+ continue;
712
+ }
713
+ if (ch === '"' || ch === "'") {
714
+ const quote = ch;
715
+ for (i++; i < css.length; i++) {
716
+ if (css[i] === '\\') { i++; continue; }
717
+ if (css[i] === quote) break;
718
+ }
719
+ continue;
720
+ }
721
+ if (ch === '{') depth++;
722
+ else if (ch === '}') {
723
+ depth--;
724
+ if (depth === 0) return i;
725
+ }
726
+ }
727
+ return -1;
728
+ }
729
+
730
+ /** 单个选择器 → 作用域化后的选择器。 */
731
+ function scopeSelector(sel, scope) {
732
+ const t = sel.trim();
733
+ if (!t) return t;
734
+ if (/^:root\b/.test(t)) return t.replace(/^:root/, scope);
735
+ if (/^html\b/i.test(t)) return t; // 作者已作用域化,别套两层
736
+ return scope + ' ' + t;
737
+ }
738
+
739
+ /** 选择器列表 → 逐项作用域化。**不切括号内的逗号**(:is()/:not()/[a="b,c"])。 */
740
+ function scopeSelectorList(list, scope) {
741
+ const out = [];
742
+ let depth = 0;
743
+ let cur = '';
744
+ for (const ch of list) {
745
+ if (ch === '(' || ch === '[') depth++;
746
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
747
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
748
+ cur += ch;
749
+ }
750
+ out.push(cur);
751
+ return out.map((s) => scopeSelector(s, scope)).join(', ');
752
+ }
753
+
754
+ const GROUP_AT = /^@(media|supports|layer|container|scope)\b/i;
755
+ const KEEP_AT = /^@(keyframes|-webkit-keyframes|font-face|page|property|counter-style|import|charset|namespace)\b/i;
756
+
757
+ /** 把整段 CSS 作用域化。看不懂的结构就原样跳过该段(宁可少转,不可转坏)。 */
758
+ function scopeCss(css, id) {
759
+ const scope = `html[data-dsh-skin="${id}"]`;
760
+ let out = '';
761
+ let i = 0;
762
+ while (i < css.length) {
763
+ if (css[i] === '/' && css[i + 1] === '*') { // 注释原样保留
764
+ const end = css.indexOf('*/', i + 2);
765
+ if (end < 0) { out += css.slice(i); break; }
766
+ out += css.slice(i, end + 2);
767
+ i = end + 2;
768
+ continue;
769
+ }
770
+ if (/\s/.test(css[i])) { out += css[i++]; continue; }
771
+
772
+ if (css[i] === '@') {
773
+ const headEnd = css.indexOf('{', i);
774
+ const semi = css.indexOf(';', i);
775
+ if (semi >= 0 && (headEnd < 0 || semi < headEnd)) { // 无块 at-rule
776
+ out += css.slice(i, semi + 1);
777
+ i = semi + 1;
778
+ continue;
779
+ }
780
+ if (headEnd < 0) { out += css.slice(i); break; }
781
+ const head = css.slice(i, headEnd).trim();
782
+ const close = matchBrace(css, headEnd);
783
+ if (close < 0) { out += css.slice(i); break; }
784
+ const inner = css.slice(headEnd + 1, close);
785
+ // 分组 at-rule 递归进去;其余(关键帧/字体面/未知)原样保留
786
+ out += head + '{' + (GROUP_AT.test(head) ? scopeCss(inner, id) : inner) + '}';
787
+ i = close + 1;
788
+ continue;
789
+ }
790
+
791
+ const braceIdx = css.indexOf('{', i); // 普通规则
792
+ if (braceIdx < 0) { out += css.slice(i); break; }
793
+ const selector = css.slice(i, braceIdx);
794
+ const close = matchBrace(css, braceIdx);
795
+ if (close < 0) { out += css.slice(i); break; }
796
+ out += scopeSelectorList(selector, scope) + '{' + css.slice(braceIdx + 1, close) + '}';
797
+ i = close + 1;
798
+ }
799
+ return out;
800
+ }
801
+
802
+ /* ── 皮肤中心「加工后 CSS」的宿主端代理 ──────────────────────────────────────
803
+ * 皮肤中心的 `/skins/<id>/stylesheet|patches` 是 `transformSkinCss()` 的产物:
804
+ * **每条规则被强制包进 `html[data-dsh-skin="<id>"]`**,特异性高于应用自身规则 ——
805
+ * 只有那份 CSS 才能真正把面板压透。皮肤目录里的原始 CSS 压不住,于是"显示不全"。
806
+ *
807
+ * 为什么需要代理:浏览器直接请求 `/api/skin-center/v2/...` 被 DSH 网关挡掉
808
+ *(实测前端一直落到"未作用域化"的降级分支)。宿主端在本机回环上再发一次、并**带上
809
+ * 浏览器这次请求的 Cookie 与同源头**,就能拿到那份产物。
810
+ *
811
+ * 拿不到(皮肤中心没装 / 该皮肤不在它的 catalog 里)一律 404,客户端据此回退到原始 CSS。
812
+ */
813
+ disposers.push(webServer.register({
814
+ kind: 'exact',
815
+ path: `${base}/skins/scoped-css`,
816
+ handler: async (req, res) => {
817
+ const url = new URL(req.url || '/', 'http://x');
818
+ const id = url.searchParams.get('id') || '';
819
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
820
+ const host = req && req.headers && req.headers.host;
821
+ if (!host) { res.statusCode = 500; res.end(); return; }
822
+ const origin = 'http://' + host;
823
+ // 透传**全部**入站头(网关认的是这次会话的凭证,可能是 cookie,也可能是某个自定义
824
+ // 头;只挑几个字段必漏),再去掉逐跳头并补上同源语义。
825
+ // 实测:裸请求与"只带 sec-fetch-*"都回 401 ⇒ 凭证不在这几个头里。
826
+ const HOP_BY_HOP = new Set([
827
+ 'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te', 'trailer',
828
+ 'proxy-authorization', 'proxy-authenticate', 'content-length', 'accept-encoding',
829
+ ]);
830
+ const fwdHeaders = {};
831
+ for (const [k, v] of Object.entries(req.headers || {})) {
832
+ const key = k.toLowerCase();
833
+ if (HOP_BY_HOP.has(key)) continue;
834
+ if (typeof v === 'string') fwdHeaders[key] = v;
835
+ else if (Array.isArray(v)) fwdHeaders[key] = v.join(', ');
836
+ }
837
+ fwdHeaders.origin = origin;
838
+ fwdHeaders.referer = origin + '/';
839
+ fwdHeaders['sec-fetch-site'] = 'same-origin';
840
+ fwdHeaders['sec-fetch-mode'] = 'cors';
841
+ fwdHeaders['sec-fetch-dest'] = 'empty';
842
+ fwdHeaders.accept = 'text/css,*/*;q=0.1';
843
+
844
+ for (const part of ['stylesheet', 'patches']) {
845
+ try {
846
+ const r = await fetch(origin + '/api/skin-center/v2/skins/' + encodeURIComponent(id) + '/' + part, {
847
+ headers: fwdHeaders,
848
+ signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
849
+ });
850
+ if (!r.ok) continue;
851
+ const text = await r.text();
852
+ if (!text || !text.trim()) continue;
853
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
854
+ res.setHeader('Cache-Control', 'no-store');
855
+ res.setHeader('X-Folia-Scoped', 'skin-center');
856
+ res.end(text);
857
+ return;
858
+ } catch { /* 这一段失败就试下一段 */ }
859
+ }
860
+
861
+ // 代理拿不到(网关拦截 / 没装皮肤中心)⇒ **自己作用域化**。
862
+ // 这条路不依赖任何凭证,也不依赖对方的进程,对所有用户一致成立。
863
+ const harness = harnessRoot();
864
+ if (!harness) { res.statusCode = 503; res.end(); return; }
865
+ const dir = join(harness, SKINS_DIR_NAME, id);
866
+ const sheet = skinStylesheetOf(dir);
867
+ if (!sheet) { res.statusCode = 404; res.end(); return; }
868
+ const raw = [];
869
+ try { raw.push(readFileSync(join(dir, ...sheet.rel.split('/')), 'utf8')); } catch { /* 主样式读不到就不能算成功 */ }
870
+ if (raw.length === 0) { res.statusCode = 404; res.end(); return; }
871
+ const patches = skinAuxFileOf(dir, 'patches');
872
+ if (patches) {
873
+ try { raw.push(readFileSync(join(dir, ...patches.split('/')), 'utf8')); } catch { /* 补丁缺失可接受 */ }
874
+ }
875
+ // url() 先改写成我们的素材路由(作用域化只动选择器,不动声明)。
876
+ const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
877
+ const absUrl = (p) => {
878
+ if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
879
+ const rel = (baseDir ? baseDir + '/' : '') + p;
880
+ return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
881
+ };
882
+ const merged = raw.join('\n/* ---- patches ---- */\n')
883
+ .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${absUrl(p.trim())}")`);
884
+ const scoped = scopeCss(merged, id);
885
+ note('[skins] scoped locally: ' + id + ' (' + scoped.length + ' bytes)');
886
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
887
+ res.setHeader('Cache-Control', 'no-store');
888
+ res.setHeader('X-Folia-Scoped', 'local');
889
+ res.end(scoped);
890
+ },
891
+ }));
892
+
683
893
  disposers.push(webServer.register({
684
894
  kind: 'exact',
685
895
  path: `${base}/skins/asset`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "2.9.1",
3
+ "version": "3.0.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {