dsh-folia 4.12.4 → 4.13.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/cordis.patch.yml +15 -25
- package/lib/client.js +270 -28
- package/lib/routes/skins.js +171 -7
- package/package.json +1 -2
package/cordis.patch.yml
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# dsh-plugin-folia bundle patch.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
3
|
+
# ⚠️ 这里**只有一条** row。皮肤中心由桌面端/市场自己那条 row 负责,我们不再插第二条(见下)。
|
|
4
4
|
#
|
|
5
|
-
# ──
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# 解到它读的那个目录(`$DSH_HOME/skins`)。所以它必须是**装得上的**:本包把它列为
|
|
9
|
-
# `dependencies`,并在下面插一条 row —— 用户装 dsh-folia 就同时得到两件套,不用再单独装。
|
|
5
|
+
# ── 为什么不再声明 skin-center 的 row 与依赖(2026-10-30 实测)─────────────────
|
|
6
|
+
# 我们一厢情愿地"随身携带"皮肤中心(`dependencies` + 第二条 insert row),结果与桌面端自己
|
|
7
|
+
# 管理的同一条 row 撞了:`.dsh-market/log.ndjson` 每次启动都会写
|
|
10
8
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# 以 `globalThis[Symbol.for('dsh-web.mounted-plugins')]` 记账 ⇒ 第二次挂载是空操作,
|
|
14
|
-
# 不会重复注册 webserver 路由。
|
|
15
|
-
# 2) 客户端半边:它的设置分区注册包在 `try { ctx.slots.register(...) } catch { return () => {} }`,
|
|
16
|
-
# 重复注册被吞掉 ⇒ 不会出现两张「皮肤」设置页。
|
|
17
|
-
# 于是「用户已有 dsh-web-all(它也插了一条 row)」与「只用我们」两种情形都成立。
|
|
9
|
+
# "@linxin666/dsh-client-ui-skin-center -> off: fiber=true"
|
|
10
|
+
# "plugin kept off: @linxin666/dsh-client-ui-skin-center"
|
|
18
11
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
12
|
+
# 也就是**它被关掉了**,于是 `/api/skin-center/v2/*` 整族变成路由 miss(带会话实测:404),
|
|
13
|
+
# 「助手皮肤」页签里点「应用」必然失败。把依赖钉到 0.4.8 只在那一次启动里生效,桌面端重启后
|
|
14
|
+
# 会把 0.4.12 投影回来(engines 要求 `dsh >= 0.2.1-rc.3`,而当前桌面端是 rc.2)→ 又被关掉。
|
|
15
|
+
# 直接改 profile 的 package.json / overrides 会让桌面端"Profile 恢复未完成"进安全模式 —— 那条路
|
|
16
|
+
# 我们已经走过一次并回滚,不再重复。
|
|
17
|
+
#
|
|
18
|
+
# 结论:**不由我们替用户装这个插件**。它该由用户在自己的插件市场/桌面端里装(那里管版本与引擎
|
|
19
|
+
# 匹配),我们的页签只做两件事:把站点 zip 解到 `$DSH_HOME/skins`,以及在它确实在跑时写选择。
|
|
20
|
+
# 判据见 test/verify-skin-center-bundle.mjs:**只允许一条 row**,且不得再声明为依赖。
|
|
21
21
|
#
|
|
22
22
|
# A patch replaces the targeted row's whole `config`, so only additive `insert`
|
|
23
23
|
# rows appear here (we never override an existing shipped row).
|
|
@@ -25,13 +25,3 @@
|
|
|
25
25
|
- insert:
|
|
26
26
|
- id: folia
|
|
27
27
|
name: 'dsh-folia'
|
|
28
|
-
# 皮肤中心:独立 npm 包(BSD-3-Clause),本包作为依赖携带。
|
|
29
|
-
#
|
|
30
|
-
# ⚠️ row id **不能**以 `folia-` 开头:`lib/subpackage-boot.js#subpackageSpecifiers` 把
|
|
31
|
-
# 「id 以 `folia-` 开头的行」当作**我们自己的子包行**做启动自检(解析不到就抛,因为宿主
|
|
32
|
-
# 对行级失败是静默的)。给第三方行起 `folia-skin-center` 这种 id 会被误判成"我们的子包"
|
|
33
|
-
# ⇒ 自检去仓库 node_modules 里解析它,开发树里没有这个依赖就直接抛,整个 apply 失败。
|
|
34
|
-
# 所以这里用不撞前缀的 `skin-center`(也与 web-all 的 `web-ui-skin-center`、
|
|
35
|
-
# 官方包的 `ui-skin-center` 都不同名)。
|
|
36
|
-
- id: skin-center
|
|
37
|
-
name: '@linxin666/dsh-client-ui-skin-center'
|
package/lib/client.js
CHANGED
|
@@ -18317,14 +18317,24 @@ window.__ModuleLoader__.load({
|
|
|
18317
18317
|
const resp = await apiJson("/skins/list" + (force ? "?refresh=1" : ""), { parse: "always" });
|
|
18318
18318
|
const doc = (resp && resp.data) || null;
|
|
18319
18319
|
if (resp && resp.ok && doc && doc.ok) {
|
|
18320
|
-
//
|
|
18321
|
-
//
|
|
18322
|
-
//
|
|
18323
|
-
//
|
|
18324
|
-
//
|
|
18320
|
+
// 皮肤中心在不在跑,**由浏览器判定**,不信宿主探测的结论。
|
|
18321
|
+
// 宿主 fetch 不带会话:`/api/**` 一旦被鉴权挡在前面就只会拿到 401,而 401 既可能是
|
|
18322
|
+
// "装了但需要会话"也可能是"路由没挂载",从宿主那一侧**无法区分**(实测踩过:
|
|
18323
|
+
// 报文纯 401 就把整个页签的「应用」按钮锁死)。浏览器同源请求天然带会话,
|
|
18324
|
+
// 拿得到就是真在跑,拿不到再退回宿主给的三态。
|
|
18325
|
+
if (doc.skinCenter !== "present") {
|
|
18326
|
+
try {
|
|
18327
|
+
const cat = await apiJson("/api/skin-center/v2/catalog", { parse: "always" });
|
|
18328
|
+
if (cat && cat.ok && cat.data && cat.data.ok) doc.skinCenter = "present";
|
|
18329
|
+
} catch { /* 仍旧用宿主给的状态 */ }
|
|
18330
|
+
}
|
|
18331
|
+
// 读"当前在用哪套皮肤":**先直连**(同源相对路径 `/api/**`,凭据天然带全),
|
|
18332
|
+
// 直连不通(页面的 origin 可能不等于宿主 HTTP 服务的 origin ⇒ 路由没到)再退回宿主代理
|
|
18333
|
+
// `/folia/skins/active` —— 代理会把浏览器这一侧的 cookie 与同源头转发过去。
|
|
18325
18334
|
if (doc.skinCenter === "present") {
|
|
18326
18335
|
try {
|
|
18327
|
-
|
|
18336
|
+
let a = await apiJson("/api/skin-center/v2/active", { parse: "always" });
|
|
18337
|
+
if (!(a && a.ok)) a = await apiJson("/skins/active", { parse: "always" });
|
|
18328
18338
|
if (a && a.ok) doc.active = (a.data && a.data.active) || "";
|
|
18329
18339
|
} catch { /* 读不到就显示"无使用中" */ }
|
|
18330
18340
|
}
|
|
@@ -18361,18 +18371,38 @@ window.__ModuleLoader__.load({
|
|
|
18361
18371
|
return;
|
|
18362
18372
|
}
|
|
18363
18373
|
}
|
|
18364
|
-
//
|
|
18365
|
-
//
|
|
18366
|
-
//
|
|
18367
|
-
//
|
|
18368
|
-
|
|
18374
|
+
// 写入有两条路,**先直连、再代理**:
|
|
18375
|
+
// ① 直连:相对 `/api/skin-center/v2/active`(同源、凭据天然带全)。
|
|
18376
|
+
// 前提是页面的 origin 就是宿主 HTTP 服务那个源;桌面端内嵌页面的 origin 未必是它 ⇒ 路由没到 ⇒ 404。
|
|
18377
|
+
// ② 代理:`/folia/skins/activate`,宿主把浏览器这侧的 cookie 与同源头转发给皮肤中心。
|
|
18378
|
+
// 这条在"origin 不一致"时仍然成立,所以作为兜底存在。
|
|
18379
|
+
// 两条都失败时把两边的状态码都显示出来 —— 下一次不用再猜是哪一段断的。
|
|
18380
|
+
const direct = await apiPostJson("/api/skin-center/v2/active", { active: wantId }, { parse: "always" });
|
|
18381
|
+
let resp = direct;
|
|
18382
|
+
if (!(resp && resp.ok)) {
|
|
18383
|
+
resp = await apiPostJson("/skins/activate", { id: wantId || "" }, { parse: "always" });
|
|
18384
|
+
}
|
|
18369
18385
|
if (!(resp && resp.ok)) {
|
|
18370
|
-
|
|
18371
|
-
|
|
18372
|
-
|
|
18373
|
-
setNote({
|
|
18386
|
+
const up = (resp && resp.data && resp.data.upstream) || null;
|
|
18387
|
+
const det = (up && up.error) ? ":" + up.error
|
|
18388
|
+
: ((resp && resp.data && resp.data.error) ? ":" + resp.data.error : "");
|
|
18389
|
+
setNote({
|
|
18390
|
+
ok: false,
|
|
18391
|
+
text: weT("操作失败:{code}", {
|
|
18392
|
+
code: "direct=" + ((direct && direct.status) || 0) + " proxy=" + ((resp && resp.status) || 0) + det,
|
|
18393
|
+
}),
|
|
18394
|
+
});
|
|
18374
18395
|
return;
|
|
18375
18396
|
}
|
|
18397
|
+
// 关键:让**皮肤赢**。皮肤中心检测到 `body[data-we-wallpaper]` 时会**主动停画**
|
|
18398
|
+
// (它的 withhold 合约:壁纸在台时它不上台),所以"写成功但画面不变"完全合理 ——
|
|
18399
|
+
// 用户在这个页签点「应用」,意图就是让皮肤接管外观,因此这里主动进入「皮肤让路」态:
|
|
18400
|
+
// 清掉我们的壁纸层 + 摘下玻璃门控(client.js 里既有的 enterSkinYield,记忆会落盘,
|
|
18401
|
+
// 点「恢复默认外观」时按记忆放回壁纸)。
|
|
18402
|
+
try {
|
|
18403
|
+
if (wantId && typeof enterSkinYield === "function") enterSkinYield("folia-tab-apply");
|
|
18404
|
+
if (!wantId && typeof exitSkinYield === "function") exitSkinYield("folia-tab-clear");
|
|
18405
|
+
} catch { /* 让路是增强:失败也不影响皮肤中心的写入 */ }
|
|
18376
18406
|
// 两条收敛路径:① 广播皮肤中心自己监听的 `dsh-skin-applied`(立即就地切换);
|
|
18377
18407
|
// ② 它另外每 2 秒轮询 /api/skin-center/v2/active(事件丢了也会在 2 秒内跟上)。
|
|
18378
18408
|
try {
|
|
@@ -18380,7 +18410,9 @@ window.__ModuleLoader__.load({
|
|
|
18380
18410
|
} catch { /* 老环境没有 CustomEvent 也无所谓,②会兜住 */ }
|
|
18381
18411
|
setNote({
|
|
18382
18412
|
ok: true,
|
|
18383
|
-
text: wantId
|
|
18413
|
+
text: wantId
|
|
18414
|
+
? weT("已切换到:{name}(本插件的壁纸与玻璃已让位)", { name: label || "" })
|
|
18415
|
+
: weT("已恢复官方默认外观(壁纸已按记忆放回)"),
|
|
18384
18416
|
});
|
|
18385
18417
|
await load(true);
|
|
18386
18418
|
return;
|
|
@@ -18436,21 +18468,33 @@ window.__ModuleLoader__.load({
|
|
|
18436
18468
|
// 不在 → 明说它是个**独立插件**(不在 dsh-web-all 里),给一条可复制的安装命令
|
|
18437
18469
|
// unknown(探测失败)→ 保守地按"在"讲,不吓唬用户
|
|
18438
18470
|
const skinCenter = (state && state.skinCenter) || "unknown";
|
|
18471
|
+
// `present` 是**唯一**允许点「应用」的状态:只有真的拿到 catalog JSON 才算它在跑。
|
|
18472
|
+
// `absent`=路由不存在(插件没装/未启用);`auth-gated`=鉴权在路由前面挡住(无法判定);
|
|
18473
|
+
// `unknown`=探测失败。后三种都如实告诉用户"现在点应用不会成功",而不是让他点了看 404。
|
|
18474
|
+
const canApply = skinCenter === "present";
|
|
18439
18475
|
if (skinCenter === "absent") {
|
|
18440
18476
|
children.push(React.createElement("div", {
|
|
18441
18477
|
key: "hint-absent",
|
|
18442
18478
|
className: "we-picker__hint",
|
|
18443
18479
|
style: { margin: "6px 0 4px", color: "#ffd08a", lineHeight: "1.6" },
|
|
18444
|
-
}, weT("
|
|
18480
|
+
}, weT("皮肤中心**没在运行**(它的 /api/skin-center/v2/* 路由不存在)。它是独立插件 @linxin666/dsh-client-ui-skin-center;常见原因是版本与当前桌面端不匹配 —— 0.4.9+ 要求 dsh ≥ 0.2.1-rc.3,而本机是 rc.2,市场会把这条行保持为 off。装/降级到 0.4.8 即可:")));
|
|
18445
18481
|
children.push(React.createElement("div", { key: "cmd", style: { display: "flex", gap: "8px", alignItems: "center", margin: "0 0 6px" } },
|
|
18446
18482
|
React.createElement("code", {
|
|
18447
18483
|
style: { fontSize: "11px", padding: "3px 8px", borderRadius: "6px", background: "rgba(255,255,255,0.08)" },
|
|
18448
|
-
}, "dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center"),
|
|
18484
|
+
}, "dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center@0.4.8"),
|
|
18449
18485
|
React.createElement("button", {
|
|
18450
18486
|
className: "we-picker__btn", type: "button",
|
|
18451
|
-
onClick: () => { try { navigator.clipboard.writeText("dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center"); setNote({ ok: true, text: weT("已复制安装命令") }); } catch { setNote({ ok: false, text: weT("复制失败,请手动选中上面的命令") }); } },
|
|
18487
|
+
onClick: () => { try { navigator.clipboard.writeText("dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center@0.4.8"); setNote({ ok: true, text: weT("已复制安装命令") }); } catch { setNote({ ok: false, text: weT("复制失败,请手动选中上面的命令") }); } },
|
|
18452
18488
|
}, weT("复制命令")),
|
|
18453
18489
|
));
|
|
18490
|
+
} else if (skinCenter === "auth-gated" || skinCenter === "unknown") {
|
|
18491
|
+
children.push(React.createElement("div", {
|
|
18492
|
+
key: "hint-unknown",
|
|
18493
|
+
className: "we-picker__hint",
|
|
18494
|
+
style: { margin: "6px 0 4px", color: "#ffd08a", lineHeight: "1.6" },
|
|
18495
|
+
}, skinCenter === "auth-gated"
|
|
18496
|
+
? weT("读不到皮肤中心的清单(浏览器侧也被鉴权挡住)。先点一次「刷新」;仍不行就说明它没在运行 —— 去设置里确认皮肤中心已启用。")
|
|
18497
|
+
: weT("皮肤中心状态未知(探测超时/网络错误)。点「刷新」重试。")));
|
|
18454
18498
|
} else {
|
|
18455
18499
|
children.push(React.createElement("div", {
|
|
18456
18500
|
key: "hint",
|
|
@@ -18458,6 +18502,8 @@ window.__ModuleLoader__.load({
|
|
|
18458
18502
|
style: { margin: "6px 0 4px", opacity: 0.8 },
|
|
18459
18503
|
}, weT("本页签可以直接切换皮肤:装完点「应用」即可,无需打开设置页。皮肤包落在皮肤中心目录,两边读同一份。")));
|
|
18460
18504
|
}
|
|
18505
|
+
// 皮肤中心不在跑时,「应用」按钮直接禁用并给出原因 —— 比"点了报错"诚实。
|
|
18506
|
+
const applyBlockedReason = canApply ? "" : weT("读不到皮肤中心的清单,先点「刷新」;仍不行说明它没在运行");
|
|
18461
18507
|
|
|
18462
18508
|
// 提示行常驻占位(与开机动画/桌宠页签同一条纪律:不因 note 出现/消失让面板跳高度)
|
|
18463
18509
|
children.push(React.createElement("div", {
|
|
@@ -18526,7 +18572,8 @@ window.__ModuleLoader__.load({
|
|
|
18526
18572
|
? React.createElement("span", { style: { ...SKIN_STYLE.badge, background: "rgba(60,160,90,0.55)" } }, weT("使用中"))
|
|
18527
18573
|
: React.createElement("button", {
|
|
18528
18574
|
className: "we-picker__btn", type: "button",
|
|
18529
|
-
disabled: Boolean(busy),
|
|
18575
|
+
disabled: Boolean(busy) || !canApply,
|
|
18576
|
+
title: applyBlockedReason || undefined,
|
|
18530
18577
|
onClick: () => act("activate", { id: s.id }, s.name),
|
|
18531
18578
|
}, busyThis ? weT("切换中…") : weT("应用")),
|
|
18532
18579
|
React.createElement("button", {
|
|
@@ -18572,7 +18619,8 @@ window.__ModuleLoader__.load({
|
|
|
18572
18619
|
: React.createElement("button", {
|
|
18573
18620
|
key: "apply",
|
|
18574
18621
|
className: "we-picker__btn", type: "button",
|
|
18575
|
-
disabled: Boolean(busy),
|
|
18622
|
+
disabled: Boolean(busy) || !canApply,
|
|
18623
|
+
title: applyBlockedReason || undefined,
|
|
18576
18624
|
onClick: () => act("activate", { id: localId }, s.title),
|
|
18577
18625
|
}, busy === "activate:" + localId ? weT("切换中…") : weT("应用")),
|
|
18578
18626
|
React.createElement("span", { key: "done", style: SKIN_STYLE.badge }, weT("已安装")),
|
|
@@ -18591,6 +18639,170 @@ window.__ModuleLoader__.load({
|
|
|
18591
18639
|
return React.createElement("div", { className: "we-picker", style: { display: "flex", flexDirection: "column" } }, children);
|
|
18592
18640
|
}
|
|
18593
18641
|
|
|
18642
|
+
// ── 内联模块:src/skin-compat.js —— 皮肤兼容层:把宿主槽位翻成分栏词表(data-pane / data-dsh-frame)—— 皮肤包写的是 web-all 的 compat 标记,没装 web-all 时否则整套选择器不命中(构建期注入;勿手改本段)──
|
|
18643
|
+
/**
|
|
18644
|
+
* 皮肤兼容层:把宿主(stock DSH)的**槽位词表**翻译成皮肤包认的**分栏词表**。
|
|
18645
|
+
*
|
|
18646
|
+
* 为什么需要它(实测,不是推测):
|
|
18647
|
+
* · 皮肤包的 CSS 大量使用 `[data-pane="sidebar"]` / `[data-pane="conversation"]` /
|
|
18648
|
+
* `[data-pane="details"]` / `[data-dsh-frame]`。样例计数(按行):
|
|
18649
|
+
* verdandi 60+、xp 36+、deep-current 12、cafe-roastery 10、ember-fall 6、blueprint 4 …
|
|
18650
|
+
* 全catalog 里 `data-pane=` 出现 250+ 次。
|
|
18651
|
+
* · 但**这些属性不是宿主写的**,是 `@linxin666/dsh-web-all` 的 compat shim 写的:
|
|
18652
|
+
* web-all/lib/client.js 的 `COLUMN_SHIMS = [["[class*=\"sidebarCol\"]","data-pane=\"sidebar\""], …]`
|
|
18653
|
+
* 而皮肤中心自己也写明:"the `data-pane=\"conversation\"` attribute is stamped by the
|
|
18654
|
+
* dsh-web-all compat shim"(skin-center/lib/client.js:4171 附近的注释)。
|
|
18655
|
+
* ⇒ 没装 web-all 时,这些选择器**一条都不命中**:皮肤不是"坏了",是它认的分栏标记不在。
|
|
18656
|
+
* 症状就是"皮肤显示不全"(布局/侧栏/输入区没被皮肤接管),而装了 web-all 就正常。
|
|
18657
|
+
*
|
|
18658
|
+
* 我们的做法:**只读宿主槽位,补写分栏属性**。
|
|
18659
|
+
* · `[data-slot="sidebar"]` → 它的父元素(`div.[class*=sidebarCol]`)标 `data-pane="sidebar"`
|
|
18660
|
+
* · `[data-slot="main"]` → 它的父元素(`div.[class*=centerCol]`)标 `data-pane="conversation"`
|
|
18661
|
+
* · `[data-slot="rightbar"]` → 它的父元素(`div.[class*=rightbarCol]`)标 `data-pane="details"`
|
|
18662
|
+
* · `#root` 下的画框元素 → `data-dsh-frame`
|
|
18663
|
+
*
|
|
18664
|
+
* ⚠️ **锚点必须取"列那一级"的槽名**(sidebar / main / rightbar),取深层槽名是无效的:
|
|
18665
|
+
* 宿主 `renderSlot` 的锚点是 `ANCHOR_STYLE = { display: "contents" }` 的**无布局包裹元素**
|
|
18666
|
+
* 与列容器之间的嵌套(app.asar 实证,JSX 顺序即 DOM 顺序):
|
|
18667
|
+
*
|
|
18668
|
+
* div.frame
|
|
18669
|
+
* ├ div.sidebarCol ← children: renderSlot("sidebar") ⇒ 锚点的父元素就是列
|
|
18670
|
+
* ├ div.centerCol ← children: renderSlot("main") ⇒ 同上
|
|
18671
|
+
* └ div.rightbarCol ← children: renderSlot("rightbar") ⇒ 同上
|
|
18672
|
+
*
|
|
18673
|
+
* 而 `main.conversation` 是 `main` 的子槽(`ConversationPanel` 里 renderSlot),
|
|
18674
|
+
* `rightbar.session` 更在 `div.session` 之内 ⇒ 它们的**父元素是另一个 display:contents 锚点
|
|
18675
|
+
* 或内层包裹 div**,把 data-pane 写在那里既没有布局效果、也不是方案认的那一列。
|
|
18676
|
+
* ⇒ 所以这里既用列级槽名,又让 `paneHost` **穿过 `[data-slot]` 锚点**再落笔;
|
|
18677
|
+
* 深层槽名只作退路(宿主哪天不再给列级槽名时仍能命中)。
|
|
18678
|
+
*
|
|
18679
|
+
* 三条纪律(与仓库既有的跨插件协议一致):
|
|
18680
|
+
* ① **只写自己的标记**:不碰皮肤中心的 `data-dsh-skin` / `data-dsh-surface` / 任何 `data-we-*`;
|
|
18681
|
+
* ② **幂等**:已存在同名属性就不改(别人(web-all)先写过就以它为准);
|
|
18682
|
+
* ③ **可失败**:整段包在 try 里,异常绝不影响壁纸/玻璃主路径;观察器只在 document 就绪后装。
|
|
18683
|
+
*
|
|
18684
|
+
* 属性本身是惰性的(宿主 CSS 不用 data-pane),所以可以在启动时就补,不必等皮肤上台。
|
|
18685
|
+
*/
|
|
18686
|
+
|
|
18687
|
+
const PANE_ANCHORS = [
|
|
18688
|
+
// 列级槽名优先(实证:这三种锚点的父元素就是列容器)。
|
|
18689
|
+
{ pane: 'sidebar', selectors: ['[data-slot="sidebar"]'] },
|
|
18690
|
+
{ pane: 'conversation', selectors: ['[data-slot="main"]', '[data-slot="main.conversation"]'] },
|
|
18691
|
+
{ pane: 'details', selectors: ['[data-slot="rightbar"]', '[data-slot="rightbar.session"]', '[data-sidebar-right-panel]'] },
|
|
18692
|
+
];
|
|
18693
|
+
|
|
18694
|
+
/** 从锚点元素上溯(穿过 display:contents 的 `[data-slot]` 锚点),得到"那一列"的容器。 */
|
|
18695
|
+
function paneHost(anchor) {
|
|
18696
|
+
let el = anchor && anchor.parentElement;
|
|
18697
|
+
// 宿主的槽位锚点都是 display:contents 包裹元素(renderSlot 的 ANCHOR_STYLE),
|
|
18698
|
+
// 属性写在它身上没有布局效果 ⇒ 必须一直走到第一个"真盒子"。
|
|
18699
|
+
let guard = 0;
|
|
18700
|
+
while (el && el.hasAttribute && el.hasAttribute('data-slot') && guard < 12) {
|
|
18701
|
+
el = el.parentElement;
|
|
18702
|
+
guard += 1;
|
|
18703
|
+
}
|
|
18704
|
+
if (!el || el === document.body) return null;
|
|
18705
|
+
if (el.id === 'root') return null;
|
|
18706
|
+
if (el.hasAttribute('data-dsh-frame')) return null;
|
|
18707
|
+
return el;
|
|
18708
|
+
}
|
|
18709
|
+
|
|
18710
|
+
function stampPanes() {
|
|
18711
|
+
let stamped = 0;
|
|
18712
|
+
for (const rule of PANE_ANCHORS) {
|
|
18713
|
+
// 每条规则**各自**判断有没有命中:早先用累计计数会让后一条规则在第一条选择器空手时
|
|
18714
|
+
// 直接跳过退路选择器(conversation/details 的退路因此永远不被尝试)。
|
|
18715
|
+
let ruleStamped = 0;
|
|
18716
|
+
for (const selector of rule.selectors) {
|
|
18717
|
+
let nodes;
|
|
18718
|
+
try { nodes = document.querySelectorAll(selector); } catch { continue; }
|
|
18719
|
+
for (const node of nodes) {
|
|
18720
|
+
const host = paneHost(node);
|
|
18721
|
+
if (!host || host.hasAttribute('data-pane')) continue;
|
|
18722
|
+
host.setAttribute('data-pane', rule.pane);
|
|
18723
|
+
stamped += 1;
|
|
18724
|
+
ruleStamped += 1;
|
|
18725
|
+
}
|
|
18726
|
+
if (ruleStamped) break; // 同一 pane 命中一种锚点即可
|
|
18727
|
+
}
|
|
18728
|
+
}
|
|
18729
|
+
return stamped;
|
|
18730
|
+
}
|
|
18731
|
+
|
|
18732
|
+
function stampFrame() {
|
|
18733
|
+
try {
|
|
18734
|
+
if (document.querySelector('[data-dsh-frame]')) return 0;
|
|
18735
|
+
const root = document.getElementById('root') || document.body;
|
|
18736
|
+
if (!root) return 0;
|
|
18737
|
+
// 与 web-all 同口径:**列容器的父元素**就是画框(`sidebarCol.parentElement`);类名只作退路。
|
|
18738
|
+
const sidebarHost = document.querySelector('[data-pane="sidebar"]');
|
|
18739
|
+
const fromColumn = (sidebarHost && sidebarHost.parentElement) || null;
|
|
18740
|
+
const frame = (fromColumn && fromColumn !== document.body && fromColumn.id !== 'root' ? fromColumn : null)
|
|
18741
|
+
|| root.querySelector('div[class*="_frame"]')
|
|
18742
|
+
|| root.firstElementChild;
|
|
18743
|
+
if (!frame || frame.hasAttribute('data-dsh-frame')) return 0;
|
|
18744
|
+
if (frame === document.body || frame.id === 'root') return 0;
|
|
18745
|
+
frame.setAttribute('data-dsh-frame', '');
|
|
18746
|
+
return 1;
|
|
18747
|
+
} catch { return 0; }
|
|
18748
|
+
}
|
|
18749
|
+
|
|
18750
|
+
function stampAll() {
|
|
18751
|
+
try { return stampPanes() + stampFrame(); } catch { return 0; }
|
|
18752
|
+
}
|
|
18753
|
+
|
|
18754
|
+
/**
|
|
18755
|
+
* 定时器可缺省:**挂载点跑在不保证有定时的宿主里**。
|
|
18756
|
+
* 实测(2026-10-30,`test/verify-playback-controls.mjs` / `verify-transcode-state.mjs`):
|
|
18757
|
+
* 那几个守卫用只实现 `document` 的 fake DOM 直接 apply 客户端,**没有 `setTimeout`** ——
|
|
18758
|
+
* 这里裸调 `setTimeout` 会 `ReferenceError`,而 `ctx.effect(() => feat.mount())` 不做保护,
|
|
18759
|
+
* 异常会顺着 apply 链把**整个宿主插件**带崩(一次 17 条判据全红)。
|
|
18760
|
+
* ⇒ 有就用(优先 `window.setTimeout`,与仓库其它层同口径),没有就**当场做**,绝不抛。
|
|
18761
|
+
*/
|
|
18762
|
+
function scheduleAfter(fn, ms) {
|
|
18763
|
+
try {
|
|
18764
|
+
const st = (typeof window !== 'undefined' && typeof window.setTimeout === 'function')
|
|
18765
|
+
? window.setTimeout.bind(window)
|
|
18766
|
+
: (typeof setTimeout === 'function' ? setTimeout : null);
|
|
18767
|
+
if (!st) { fn(); return null; }
|
|
18768
|
+
return st(fn, ms);
|
|
18769
|
+
} catch { try { fn(); } catch { /* ignore */ } return null; }
|
|
18770
|
+
}
|
|
18771
|
+
|
|
18772
|
+
function cancelAfter(id) {
|
|
18773
|
+
try {
|
|
18774
|
+
if (id === null || id === undefined) return;
|
|
18775
|
+
if (typeof clearTimeout === 'function') clearTimeout(id);
|
|
18776
|
+
else if (typeof window !== 'undefined' && typeof window.clearTimeout === 'function') window.clearTimeout(id);
|
|
18777
|
+
} catch { /* ignore */ }
|
|
18778
|
+
}
|
|
18779
|
+
|
|
18780
|
+
/** 装上兼容层;返回幂等 teardown(与仓库其它 install* 同形)。整段可失败(异常不外溢)。 */
|
|
18781
|
+
function installSkinCompat() {
|
|
18782
|
+
if (typeof document === 'undefined') return () => {};
|
|
18783
|
+
try {
|
|
18784
|
+
stampAll();
|
|
18785
|
+
let observer = null;
|
|
18786
|
+
let timer = null;
|
|
18787
|
+
const schedule = () => {
|
|
18788
|
+
if (timer !== null) return;
|
|
18789
|
+
timer = scheduleAfter(() => { timer = null; stampAll(); }, 120);
|
|
18790
|
+
};
|
|
18791
|
+
try {
|
|
18792
|
+
observer = new MutationObserver(schedule);
|
|
18793
|
+
// 只观察结构性变化(子树增删);不观察属性,避免别人改属性时我们无限自激。
|
|
18794
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
18795
|
+
} catch { observer = null; }
|
|
18796
|
+
// 宿主是 SPA:路由切换会整块换掉列容器,所以补一次延迟重扫。
|
|
18797
|
+
const late = scheduleAfter(stampAll, 1500);
|
|
18798
|
+
return () => {
|
|
18799
|
+
try { if (observer) observer.disconnect(); } catch { /* ignore */ }
|
|
18800
|
+
if (timer !== null) { cancelAfter(timer); timer = null; }
|
|
18801
|
+
cancelAfter(late);
|
|
18802
|
+
};
|
|
18803
|
+
} catch { return () => {}; }
|
|
18804
|
+
}
|
|
18805
|
+
|
|
18594
18806
|
// ── 内联模块:src/features.js —— 客户端功能注册表:页签条 / 整区面板 / 常驻挂载层。装配点(quick-panel、client.js)只查表,不写功能名(构建期注入;勿手改本段)──
|
|
18595
18807
|
/**
|
|
18596
18808
|
* features.js(客户端)— **功能注册表**:页签声明、面板工厂、挂载钩子。
|
|
@@ -18639,6 +18851,9 @@ window.__ModuleLoader__.load({
|
|
|
18639
18851
|
*/
|
|
18640
18852
|
const WE_FEATURE_MOUNTS = [
|
|
18641
18853
|
{ id: "pets", mount: mountPetLayer },
|
|
18854
|
+
// 皮肤兼容层:补写 `data-pane` / `data-dsh-frame`(皮肤包写的是 web-all 的 compat 词表)。
|
|
18855
|
+
// 它只写自己的惰性属性、幂等、可失败,所以随墙纸引擎常驻,不依赖皮肤是否在台上。
|
|
18856
|
+
{ id: "skin-compat", mount: installSkinCompat },
|
|
18642
18857
|
];
|
|
18643
18858
|
|
|
18644
18859
|
/** 页签 → 功能归属(宿主清单 lib/features.js 的客户端镜像,供守卫对账)。 */
|
|
@@ -27163,9 +27378,13 @@ window.__ModuleLoader__.load({
|
|
|
27163
27378
|
* 才回收)。所以只认"过一拍还在"的出现:宽限结束时 DOM 仍带标记、且我方确实有要退的东西
|
|
27164
27379
|
* (壁纸在台 ⇒ 清层;或**无壁纸**且设置已就位 ⇒ 只剩玻璃要让)⇒ 才是动作。
|
|
27165
27380
|
* ⚠️ 450ms 必须显著短于对方的 1500ms 宽限,否则真实动作会在我们确认前被它回收。
|
|
27381
|
+
*
|
|
27382
|
+
* `opts.forced`:**标记值发生了变化**(换了一套皮肤)—— 那是用户刚做的一次显式动作,
|
|
27383
|
+
* 不存在"启动上妆"的竞态可言,所以连"我方是否有层"这条腿都不用再问(见 syncSkinYield)。
|
|
27166
27384
|
*/
|
|
27167
|
-
function scheduleSkinYieldEnter(reason) {
|
|
27385
|
+
function scheduleSkinYieldEnter(reason, opts) {
|
|
27168
27386
|
if (skinYieldEnterTimer !== null || skinYielded) return;
|
|
27387
|
+
const forced = !!(opts && opts.forced);
|
|
27169
27388
|
const fire = () => {
|
|
27170
27389
|
skinYieldEnterTimer = null;
|
|
27171
27390
|
try {
|
|
@@ -27177,8 +27396,11 @@ window.__ModuleLoader__.load({
|
|
|
27177
27396
|
// · 无壁纸腿:没有层可清、也没有竞态可防,但玻璃整族仍盖在皮肤上(玻璃与壁纸
|
|
27178
27397
|
// 正交,data-we-glass-page 恒挂)⇒ 这条腿也要进,否则无壁纸用户中途试穿 /
|
|
27179
27398
|
// 应用皮肤永远摘不掉玻璃(安装期的 adopt 只管启动那一拍)。
|
|
27180
|
-
|
|
27181
|
-
if (!
|
|
27399
|
+
// · forced(换皮肤 = 用户刚动作):跳过启动竞态那套判据,直接让路。
|
|
27400
|
+
if (!forced) {
|
|
27401
|
+
const wallMarkerOn = !!(document.body && document.body.hasAttribute("data-we-wallpaper"));
|
|
27402
|
+
if (!wallMarkerOn && (selection.id || !selection.loaded)) return;
|
|
27403
|
+
}
|
|
27182
27404
|
enterSkinYield(reason + "/dom");
|
|
27183
27405
|
} catch { /* ignore */ }
|
|
27184
27406
|
};
|
|
@@ -27222,10 +27444,24 @@ window.__ModuleLoader__.load({
|
|
|
27222
27444
|
} catch { fire(); }
|
|
27223
27445
|
}
|
|
27224
27446
|
|
|
27225
|
-
/**
|
|
27447
|
+
/** 上次看到的 `data-dsh-skin` 值(null = 还没看过;用于区分"启动上妆"与"用户换了一套")。 */
|
|
27448
|
+
let skinAttrSeen = null;
|
|
27449
|
+
|
|
27450
|
+
/**
|
|
27451
|
+
* 合议当前状态:皮肤"要求上台"且我方确实在台上 ⇒ 退场;持续不在台上则排定复位。
|
|
27452
|
+
*
|
|
27453
|
+
* **没有优先级,只有"谁最后动的"**(用户明确要求):
|
|
27454
|
+
* · 皮肤标记**值发生变化** = 用户刚换/刚应用了一套皮肤 ⇒ 我方立刻让路(哪怕壁纸正在画);
|
|
27455
|
+
* · 壁纸被选中 = `media-prep` 的钩子会 `exitSkinYield` ⇒ 皮肤侧 withhold、壁纸接管;
|
|
27456
|
+
* · 启动那一拍(值没变)= 不改判:我方有壁纸就让壁纸画(皮肤自己 withhold),
|
|
27457
|
+
* 我方无壁纸且在皮肤上 ⇒ 认领让路态(见 installSkinInterop 的 adoptOnLoaded)。
|
|
27458
|
+
*/
|
|
27226
27459
|
function syncSkinYield(reason) {
|
|
27227
27460
|
try {
|
|
27228
|
-
|
|
27461
|
+
const attr = document.documentElement.getAttribute("data-dsh-skin");
|
|
27462
|
+
const changed = attr !== skinAttrSeen;
|
|
27463
|
+
skinAttrSeen = attr;
|
|
27464
|
+
if (attr) {
|
|
27229
27465
|
cancelSkinYieldExit();
|
|
27230
27466
|
// ⚠️ 「我方在台上」有两条腿:壁纸在台(标记在场 ⇒ 有层要让)与无壁纸(设置已就位
|
|
27231
27467
|
// 且 id 为空 ⇒ 只剩玻璃要让)。启动竞态只长在壁纸腿上——对方运行时会先于我们的
|
|
@@ -27234,7 +27470,8 @@ window.__ModuleLoader__.load({
|
|
|
27234
27470
|
// 动作会清掉壁纸(实测报障:有壁纸时刷新掉壁纸)。正确的收场是:让我们的层建起来
|
|
27235
27471
|
// → 标记挂上 → 对方 watcher 自行 withhold 撤掉皮肤。无壁纸腿没有竞态(没有层可清),
|
|
27236
27472
|
// 随宽限进即可。不是立即退场:先排定一次"过一拍再确认"(见 scheduleSkinYieldEnter)。
|
|
27237
|
-
|
|
27473
|
+
// 但 `changed`(换了一套皮肤)是用户的显式动作,不走那套竞态判据(forced)。
|
|
27474
|
+
scheduleSkinYieldEnter(reason, { forced: changed });
|
|
27238
27475
|
return;
|
|
27239
27476
|
}
|
|
27240
27477
|
cancelSkinYieldEnter();
|
|
@@ -27245,6 +27482,9 @@ window.__ModuleLoader__.load({
|
|
|
27245
27482
|
/** 装上互操作(单腿:DOM 观察);返回幂等 teardown。 */
|
|
27246
27483
|
function installSkinInterop() {
|
|
27247
27484
|
if (typeof document === "undefined" || !document.documentElement) return () => {};
|
|
27485
|
+
// 启动快照:把当前标记记为"已看过",这样**启动那一拍不会被当成一次换皮肤动作**
|
|
27486
|
+
// (否则每次刷新都会误判成用户刚应用皮肤,进而清掉壁纸)。
|
|
27487
|
+
try { skinAttrSeen = document.documentElement.getAttribute("data-dsh-skin"); } catch { skinAttrSeen = null; }
|
|
27248
27488
|
let observer = null;
|
|
27249
27489
|
try {
|
|
27250
27490
|
observer = new MutationObserver(() => syncSkinYield("dom"));
|
|
@@ -27254,7 +27494,9 @@ window.__ModuleLoader__.load({
|
|
|
27254
27494
|
// · 皮肤在台上(文档交付时 tap 已画)+ 我方**没有**选中的壁纸 ⇒ 本来就该在让路态
|
|
27255
27495
|
// (玻璃退场)——认领,不清层;记忆从**盘上落的那份**拿(enter 写的
|
|
27256
27496
|
// skinYieldRestoreId / skinYieldRestoreRotation),没落过盘才退回空记忆;
|
|
27257
|
-
// · 皮肤在台上 + 我方有选中的壁纸 ⇒
|
|
27497
|
+
// · 皮肤在台上 + 我方有选中的壁纸 ⇒ **启动不改判**:保持现状(壁纸画、皮肤自己 withhold)。
|
|
27498
|
+
// 这不是"优先级",而是启动那一拍没有"谁最后动"的信息;运行期任一方动作都会立刻改判
|
|
27499
|
+
// (换皮肤 ⇒ syncSkinYield 的 forced 分支;选壁纸 ⇒ media-prep 的 exitSkinYield)。
|
|
27258
27500
|
// · 皮肤**已不在**台上 + 我方没有壁纸 + 盘上留着让路记忆 ⇒ 启动复位:按记忆放回
|
|
27259
27501
|
// (应用关闭期间皮肤被移除 / 卸载的那条路 —— 没有它,记忆落了盘也无人消费)。
|
|
27260
27502
|
// ⚠️ 认领必须等**宿主设置到位**(`selection.loaded`):本地缓存可能过期(本窗口缓存空、
|
package/lib/routes/skins.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from 'node:crypto';
|
|
22
22
|
import { inflateRawSync } from 'node:zlib';
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
24
24
|
import { homedir } from 'node:os';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
|
|
@@ -42,6 +42,32 @@ function dshHome() {
|
|
|
42
42
|
/** 皮肤根:与皮肤中心同一个目录(它读 `$DSH_HOME/skins`)。 */
|
|
43
43
|
function skinsRoot() { return join(dshHome(), 'skins'); }
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 皮肤中心持久化"当前皮肤"的那个文件。
|
|
47
|
+
*
|
|
48
|
+
* 为什么我们会直接写它(兜底路径):切换皮肤要落到 `/api/skin-center/v2/active`,
|
|
49
|
+
* 而那条路径 ① 需要 DSH 会话;② 必须打到宿主 HTTP 服务那个源。实测这两点在桌面端内嵌页面里
|
|
50
|
+
* 都可能不成立(401 / 路由没到),于是"点应用"根本没有可靠的写入通道。
|
|
51
|
+
* 这个文件是**它自己的持久层**(`{ active, initialized, background }`),它每次 GET 都重新读它,
|
|
52
|
+
* 并且客户端每 2 秒轮询一次 GET 收敛 —— 所以写这个文件等价于"替它记下选择",可靠且即时。
|
|
53
|
+
* 语义上只改 `active` 一个键,其余原样保留。
|
|
54
|
+
*/
|
|
55
|
+
function activeStatePath() { return join(dshHome(), 'skin-center-active.json'); }
|
|
56
|
+
|
|
57
|
+
/** 把 `active` 写进皮肤中心的持久层(保留其它字段;原子替换,失败不破坏原文件)。 */
|
|
58
|
+
function writeActiveStateFile(id) {
|
|
59
|
+
const p = activeStatePath();
|
|
60
|
+
let doc = {};
|
|
61
|
+
try { doc = JSON.parse(readFileSync(p, 'utf8')) || {}; } catch { doc = {}; }
|
|
62
|
+
if (typeof doc !== 'object' || Array.isArray(doc)) doc = {};
|
|
63
|
+
doc.active = id === undefined ? null : id;
|
|
64
|
+
if (doc.initialized === undefined) doc.initialized = true;
|
|
65
|
+
const tmp = p + '.folia-tmp';
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
67
|
+
renameSync(tmp, p);
|
|
68
|
+
return doc;
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
function siteBase() {
|
|
46
72
|
const raw = process.env.DSH_FOLIA_SITE;
|
|
47
73
|
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
|
|
@@ -56,20 +82,43 @@ function siteBase() {
|
|
|
56
82
|
* 前端也能问,但宿主这一侧拿到的结果与"装没装"一一对应,而且不受浏览器缓存/跨源策略影响。
|
|
57
83
|
* 判据用**它自己的 catalog 路由**:该路由不需要鉴权(实测 200),404 就是没装。
|
|
58
84
|
*/
|
|
59
|
-
const skinCenterProbe = { at: 0, state: 'unknown' };
|
|
85
|
+
const skinCenterProbe = { at: 0, state: 'unknown', detail: '' };
|
|
60
86
|
async function probeSkinCenter(hostHeader, force) {
|
|
61
87
|
const now = Date.now();
|
|
62
88
|
if (!force && skinCenterProbe.at && now - skinCenterProbe.at < 30000) return skinCenterProbe.state;
|
|
63
89
|
let state = 'unknown';
|
|
90
|
+
let detail = '';
|
|
64
91
|
if (hostHeader) {
|
|
65
92
|
try {
|
|
66
93
|
const r = await fetch('http://' + hostHeader + '/api/skin-center/v2/catalog', { signal: AbortSignal.timeout(4000) });
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
if (r.ok) {
|
|
95
|
+
// 只有**真的拿到 catalog JSON** 才算在跑。这个判据以前写成"非 404 即 present",
|
|
96
|
+
// 而 401 也会落到那一支 —— 但宿主进程没有会话,拿到的 401 只代表"鉴权挡在前面",
|
|
97
|
+
// 完全可能是插件被禁用(路由根本没注册)。实测就被它骗过:面板显示"皮肤中心在",
|
|
98
|
+
// 点应用却永远 404(真实原因:插件被市场保持为 off)。
|
|
99
|
+
let doc = null;
|
|
100
|
+
try { doc = await r.json(); } catch { doc = null; }
|
|
101
|
+
state = doc && doc.ok ? 'present' : 'auth-gated';
|
|
102
|
+
detail = state === 'present' ? '' : 'catalog-not-json';
|
|
103
|
+
} else if (r.status === 401 || r.status === 403) {
|
|
104
|
+
// 鉴权挡在前面 ⇒ **无法判定**它到底有没有挂载;如实说"不确定",不要冒充 present。
|
|
105
|
+
state = 'auth-gated';
|
|
106
|
+
detail = 'http-' + r.status;
|
|
107
|
+
} else if (r.status === 404) {
|
|
108
|
+
state = 'absent';
|
|
109
|
+
detail = 'route-missing';
|
|
110
|
+
} else {
|
|
111
|
+
state = 'unknown';
|
|
112
|
+
detail = 'http-' + r.status;
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
state = 'unknown';
|
|
116
|
+
detail = (e && e.name) || 'network';
|
|
117
|
+
}
|
|
70
118
|
}
|
|
71
119
|
skinCenterProbe.at = now;
|
|
72
120
|
skinCenterProbe.state = state;
|
|
121
|
+
skinCenterProbe.detail = detail;
|
|
73
122
|
return state;
|
|
74
123
|
}
|
|
75
124
|
|
|
@@ -274,6 +323,13 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
274
323
|
sendJson(res, 200, {
|
|
275
324
|
ok: true,
|
|
276
325
|
skinCenter,
|
|
326
|
+
skinCenterDetail: skinCenterProbe.detail || '',
|
|
327
|
+
// 皮肤中心被"保持为 off"时(peer 版本不匹配等),用户需要知道去改什么。
|
|
328
|
+
// 这里不动它,只把"它为什么可能不在"说清楚:我们钉的是 0.4.8(peer dsh >= 0.2.1-rc.2),
|
|
329
|
+
// 0.4.9+ 要求 rc.3,装了更高版本就会被市场关掉。
|
|
330
|
+
skinCenterHint: skinCenter === 'present'
|
|
331
|
+
? ''
|
|
332
|
+
: 'skin-center-0.4.8',
|
|
277
333
|
siteBase: siteBase(),
|
|
278
334
|
siteError: site.error,
|
|
279
335
|
installed,
|
|
@@ -392,6 +448,114 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
392
448
|
},
|
|
393
449
|
}));
|
|
394
450
|
|
|
395
|
-
|
|
396
|
-
|
|
451
|
+
/* ④ 代理皮肤中心的 active 端点(**转发浏览器凭据**)。
|
|
452
|
+
*
|
|
453
|
+
* 为什么需要它:写入 `/api/skin-center/v2/active` 有两个前提 ——
|
|
454
|
+
* ① 会话(DSH 对这条路径做鉴权);② 请求打到**宿主 HTTP 服务**那个源。
|
|
455
|
+
* 浏览器直连是首选(同源相对路径,凭据天然带全);但当页面的 origin 与宿主 API 的 origin
|
|
456
|
+
* 不同(桌面端内嵌页面可能是自定义协议或另一个端口),直连会 404 —— 那是**路由没到**,
|
|
457
|
+
* 而它的 body 里没有 `error` 字段,正好和皮肤中心自己的 404 `skin-not-found` 区分开。
|
|
458
|
+
*
|
|
459
|
+
* 所以这里做**兜底**:把浏览器这一侧的 cookie 与同源头一起转发过去。
|
|
460
|
+
* 只转发必要的头,不转发 `authorization`(避免把别的凭据带进第三方插件)。
|
|
461
|
+
*/
|
|
462
|
+
const FORWARD_HEADERS = ['cookie', 'origin', 'referer', 'sec-fetch-site', 'sec-fetch-mode'];
|
|
463
|
+
function forwardedHeaders(req) {
|
|
464
|
+
const out = {};
|
|
465
|
+
const src = (req && req.headers) || {};
|
|
466
|
+
for (const k of FORWARD_HEADERS) {
|
|
467
|
+
const v = src[k];
|
|
468
|
+
if (typeof v === 'string' && v) out[k] = v;
|
|
469
|
+
}
|
|
470
|
+
// 同源栅栏看的是 Sec-Fetch-Site / Origin 与 Host 的一致性:我们代理出去时 Host 变了,
|
|
471
|
+
// 所以显式把它标成 same-origin(这正是事实:浏览器是发给"自己这台的宿主"的)。
|
|
472
|
+
out['sec-fetch-site'] = 'same-origin';
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
disposers.push(webServer.register({
|
|
477
|
+
kind: 'exact',
|
|
478
|
+
path: `${base}/skins/activate`,
|
|
479
|
+
handler: async (req, res) => {
|
|
480
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
481
|
+
const host = req.headers && req.headers.host;
|
|
482
|
+
if (!host) { sendJson(res, 400, { ok: false, error: 'no-host' }); return; }
|
|
483
|
+
const body = await readJsonBody(req);
|
|
484
|
+
const raw = body && body.id;
|
|
485
|
+
const requested = (raw === null || raw === undefined || String(raw) === '') ? null : safeSkinId(raw);
|
|
486
|
+
if (raw !== null && raw !== undefined && String(raw) !== '' && !requested) {
|
|
487
|
+
sendJson(res, 400, { ok: false, error: 'bad-id' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (requested && !existsSync(join(skinsRoot(), requested))) {
|
|
491
|
+
sendJson(res, 404, { ok: false, error: 'not-installed' });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
let upstream = { status: 0, error: null };
|
|
495
|
+
try {
|
|
496
|
+
const r = await fetch('http://' + host + '/api/skin-center/v2/active', {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: { 'content-type': 'application/json', ...forwardedHeaders(req) },
|
|
499
|
+
body: JSON.stringify({ active: requested }),
|
|
500
|
+
signal: AbortSignal.timeout(6000),
|
|
501
|
+
});
|
|
502
|
+
const text = await r.text();
|
|
503
|
+
let doc = null;
|
|
504
|
+
try { doc = JSON.parse(text); } catch { /* 非 JSON(路由 miss 时常见) */ }
|
|
505
|
+
upstream = { status: r.status, error: (doc && doc.error) || null };
|
|
506
|
+
if (r.ok) {
|
|
507
|
+
sendJson(res, 200, { ok: true, active: requested, via: 'http', upstream });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
} catch (e) {
|
|
511
|
+
upstream = { status: 0, error: e && e.name === 'TimeoutError' ? 'timeout' : 'network' };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── 兜底:直接写它自己的持久层 ────────────────────────────────────────
|
|
515
|
+
// HTTP 写入不可用(无会话 / 源不一致 / 端点不在)时,写 `skin-center-active.json` 的
|
|
516
|
+
// `active` 字段:皮肤中心每次 GET 都重读这个文件,客户端又每 2 秒轮询一次 GET,
|
|
517
|
+
// 所以写文件等价于"替它记下选择",最迟 2 秒生效;我们同时还会广播 dsh-skin-applied 立即生效。
|
|
518
|
+
try {
|
|
519
|
+
writeActiveStateFile(requested);
|
|
520
|
+
sendJson(res, 200, { ok: true, active: requested, via: 'state-file', upstream });
|
|
521
|
+
} catch (e) {
|
|
522
|
+
sendJson(res, 502, {
|
|
523
|
+
ok: false,
|
|
524
|
+
error: 'activate-failed',
|
|
525
|
+
upstream,
|
|
526
|
+
detail: String((e && e.message) || e).slice(0, 160),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
disposers.push(webServer.register({
|
|
533
|
+
kind: 'exact',
|
|
534
|
+
path: `${base}/skins/active`,
|
|
535
|
+
handler: async (req, res) => {
|
|
536
|
+
const host = req.headers && req.headers.host;
|
|
537
|
+
// 先问皮肤中心自己(HTTP),问不到就**读它的持久层** —— 后者是写路径的兜底,
|
|
538
|
+
// 两边都必须能回答"现在用的是哪套",否则面板的"使用中"标记会随写入方式漂移。
|
|
539
|
+
if (host) {
|
|
540
|
+
try {
|
|
541
|
+
const r = await fetch('http://' + host + '/api/skin-center/v2/active', {
|
|
542
|
+
headers: { accept: 'application/json', ...forwardedHeaders(req) },
|
|
543
|
+
signal: AbortSignal.timeout(5000),
|
|
544
|
+
});
|
|
545
|
+
if (r.ok) {
|
|
546
|
+
const doc = await r.json().catch(() => null);
|
|
547
|
+
sendJson(res, 200, { ok: true, active: (doc && doc.active) || null, via: 'http' });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
} catch { /* 落到文件读 */ }
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
let doc = {};
|
|
554
|
+
try { doc = JSON.parse(readFileSync(activeStatePath(), 'utf8')) || {}; } catch { doc = {}; }
|
|
555
|
+
sendJson(res, 200, { ok: true, active: doc.active || null, via: 'state-file' });
|
|
556
|
+
} catch (e) {
|
|
557
|
+
sendJson(res, 502, { ok: false, error: 'read-failed', detail: String((e && e.message) || e).slice(0, 120) });
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
}));
|
|
397
561
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-folia",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
4
4
|
"description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -193,7 +193,6 @@
|
|
|
193
193
|
}
|
|
194
194
|
},
|
|
195
195
|
"dependencies": {
|
|
196
|
-
"@linxin666/dsh-client-ui-skin-center": "^0.4.8",
|
|
197
196
|
"webwallgl": "^3.0.0",
|
|
198
197
|
"x11": "^4.3.1"
|
|
199
198
|
},
|