dsh-folia 4.13.0 → 4.14.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 CHANGED
@@ -1,23 +1,24 @@
1
1
  # dsh-plugin-folia bundle patch.
2
2
  #
3
- # ⚠️ 这里**只有一条** row。皮肤中心由桌面端/市场自己那条 row 负责,我们不再插第二条(见下)。
3
+ # 两条 row:我们自己的 `folia` + 随身携带的皮肤中心 `skin-center`。
4
4
  #
5
- # ── 为什么不再声明 skin-center 的 row 与依赖(2026-10-30 实测)─────────────────
6
- # 我们一厢情愿地"随身携带"皮肤中心(`dependencies` + 第二条 insert row),结果与桌面端自己
7
- # 管理的同一条 row 撞了:`.dsh-market/log.ndjson` 每次启动都会写
5
+ # ── 皮肤中心为什么"带过一次、去掉、又带回来"(两次都是实测结论)──────────────
6
+ # 1) 4.12.x:带着它(dependencies + row)→ 0.4.12 的 engines 要求 `dsh >= 0.2.1-rc.3`,
7
+ # 而当时桌面端是 0.2.1-rc.2 ⇒ 市场把这条行**保持为 off**:
8
+ # ".dsh-market/log.ndjson": "plugin kept off: @linxin666/dsh-client-ui-skin-center"
9
+ # `/api/skin-center/v2/*` 整族变成路由 miss(带会话实测 404),页签点「应用」必失败。
10
+ # 当时把依赖钉到 0.4.8 只在一两次启动里有效,桌面端会把 0.4.12 投影回来 ⇒ 又 off。
11
+ # 2) 现在:桌面端基线升到 0.2.1-rc.3+(本机实测 dsh=0.2.1-rc.3),engines 满足 ⇒ 可以正常携带。
12
+ # 携带的意义:用户装 dsh-folia 就同时得到"皮肤渲染方",不必再自己去市场找。
8
13
  #
9
- # "@linxin666/dsh-client-ui-skin-center -> off: fiber=true"
10
- # "plugin kept off: @linxin666/dsh-client-ui-skin-center"
11
- #
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**,且不得再声明为依赖。
14
+ # ── 仍然保留的纪律 ─────────────────────────────────────────────────────────
15
+ # · 重复挂载是安全的(实测):宿主侧 apply 走 `mountOnce`,客户端 slots.register 包 try/catch
16
+ # ⇒ 用户市场里再装一份也不会打起来(第二次是空操作)。
17
+ # · 若某台机器 dsh 低于 0.4.12 的要求:市场会 keep off,我们的页签会如实显示
18
+ # "皮肤中心未运行",壁纸/桌宠/开机动画/玻璃**完全不受影响**(探测四态 + 应用禁用)。
19
+ # · row id 不得以 `folia-` 开头:`lib/subpackage-boot.js#subpackageSpecifiers` 会把
20
+ # 「id 以 folia- 开头的行」当成我们自己的子包去仓库解析(解析不到就抛)。
21
+ # · 依赖版本**精确**(不带 ^):0.4.9+ 的 engines 会随上游再抬,我们不能让用户被动中招。
21
22
  #
22
23
  # A patch replaces the targeted row's whole `config`, so only additive `insert`
23
24
  # rows appear here (we never override an existing shipped row).
@@ -25,3 +26,5 @@
25
26
  - insert:
26
27
  - id: folia
27
28
  name: 'dsh-folia'
29
+ - id: skin-center
30
+ name: '@linxin666/dsh-client-ui-skin-center'
package/lib/client.js CHANGED
@@ -816,7 +816,6 @@ window.__ModuleLoader__.load({
816
816
  "恢复默认外观": "Restore default look",
817
817
  "应用": "Apply",
818
818
  "切换中…": "Switching…",
819
- "使用中": "Active",
820
819
  "正在使用,先切到别的再卸": "Currently active — switch to another skin before removing",
821
820
  "已切换到:{name}": "Switched to: {name}",
822
821
  "已恢复官方默认外观": "Restored the default look",
@@ -18340,7 +18339,12 @@ window.__ModuleLoader__.load({
18340
18339
  }
18341
18340
  setState(doc);
18342
18341
  if (doc.siteError && doc.siteError !== "site-off") {
18343
- setNote({ ok: false, text: weT("站点清单读不到:{msg}", { msg: String(doc.siteError) }) });
18342
+ // `site-locked` = 站点整站上锁(登录页 + {"code":"site_locked"}),不是网络/插件问题。
18343
+ // 给它一句能照做的说明,而不是甩一个 http-401(实测:这个错误码让人误判成插件坏了)。
18344
+ const msg = doc.siteError === "site-locked"
18345
+ ? weT("站点已锁定:只对已登录的管理员开放(需站点侧放行 GET /api/public/wallpapers)")
18346
+ : String(doc.siteError);
18347
+ setNote({ ok: false, text: weT("站点清单读不到:{msg}", { msg }) });
18344
18348
  }
18345
18349
  } else {
18346
18350
  setNote({ ok: false, text: weT("读取失败:{msg}", { msg: (doc && doc.error) || "unknown" }) });
@@ -18468,11 +18472,19 @@ window.__ModuleLoader__.load({
18468
18472
  // 不在 → 明说它是个**独立插件**(不在 dsh-web-all 里),给一条可复制的安装命令
18469
18473
  // unknown(探测失败)→ 保守地按"在"讲,不吓唬用户
18470
18474
  const skinCenter = (state && state.skinCenter) || "unknown";
18475
+ // 用户在插件市场里手动禁用了它 —— 这是最准确的原因,优先于所有 http 状态码推测。
18476
+ const marketDisabled = Boolean(state && state.skinCenterDisabled);
18471
18477
  // `present` 是**唯一**允许点「应用」的状态:只有真的拿到 catalog JSON 才算它在跑。
18472
18478
  // `absent`=路由不存在(插件没装/未启用);`auth-gated`=鉴权在路由前面挡住(无法判定);
18473
18479
  // `unknown`=探测失败。后三种都如实告诉用户"现在点应用不会成功",而不是让他点了看 404。
18474
18480
  const canApply = skinCenter === "present";
18475
- if (skinCenter === "absent") {
18481
+ if (marketDisabled) {
18482
+ children.push(React.createElement("div", {
18483
+ key: "hint-disabled",
18484
+ className: "we-picker__hint",
18485
+ style: { margin: "6px 0 4px", color: "#ffd08a", lineHeight: "1.6" },
18486
+ }, weT("皮肤中心被**你在插件市场里禁用了**(市场记录里它在 disabled 列表里)。皮肤的实际渲染方就是它,禁用它 ⇒ 这里只能装/卸皮肤包,不能应用。到插件市场把它重新启用即可(dsh-folia 已把它作为依赖随身携带,不需要另装)。")));
18487
+ } else if (skinCenter === "absent") {
18476
18488
  children.push(React.createElement("div", {
18477
18489
  key: "hint-absent",
18478
18490
  className: "we-picker__hint",
@@ -20043,8 +20055,14 @@ window.__ModuleLoader__.load({
20043
20055
  // "露出底色"照旧不发生。
20044
20056
  // 场景内嵌 MP4 的 poster 是静态帧,是「先静帧后动态」的既有设计,保留 —— 判据见
20045
20057
  // rotation-prepared-leak-smoke.mjs 的 T2 / T2b(无静态缩略图仍必须等帧)。
20058
+ // 站点(远程)壁纸也带静态缩略图(`thumbUrl`,webp/jpg)。旧判据只认**本机 ffmpeg 抽的**
20059
+ // `/video-preview/`,于是远程视频**没有 poster** ⇒ 闸门必须等首个可解码帧才放行。
20060
+ // 实测代价:一条 8~29 MB 的 4K 远程源,切换要等数秒(首帧在网络那头)。
20061
+ // 现在把**静态图后缀**也认作 poster(`.gif` 仍排除 —— 动图当 poster 会"先播预览",
20062
+ // 那正是这条判据当初要挡的东西)。放行条件不变:poster **真的加载出来**才算有画面。
20063
+ const siteStaticThumb = !!sel.previewUrl && /\.(jpe?g|png|webp|avif)(?:[?#]|$)/i.test(sel.previewUrl);
20046
20064
  const stillPreview = !!sel.previewUrl
20047
- && (sel.type !== "video" || /\/video-preview\//.test(sel.previewUrl));
20065
+ && (sel.type !== "video" || /\/video-preview\//.test(sel.previewUrl) || siteStaticThumb);
20048
20066
  if (stillPreview) media.poster = sel.previewUrl;
20049
20067
  }
20050
20068
  media.autoplay = true;
@@ -21094,7 +21112,14 @@ window.__ModuleLoader__.load({
21094
21112
  return;
21095
21113
  }
21096
21114
  const savedVariant = Number(selection.frameVariants && selection.frameVariants[String(w.id)]) || 0;
21097
- selection.url = w.type === "scene" ? frameUrlWithVariant(w.frameUrl, savedVariant) : w.media;
21115
+ // 站点条目带 `mediaProxy`(宿主缓存代理,失败一律 302 回原 URL)⇒ 优先用它播放:
21116
+ // 首次边下边播并落盘,之后同一张直接读磁盘,切换不再等网络。没有代理就直连 `w.media`。
21117
+ const playableSrc = w.type === "scene"
21118
+ ? frameUrlWithVariant(w.frameUrl, savedVariant)
21119
+ : (w.mediaProxy || w.media);
21120
+ selection.url = playableSrc;
21121
+ // `media` 仍保留原始站点地址:下载 / 兜底 / 诊断用它(不经过我们的代理)。
21122
+ selection.originUrl = w.media || null;
21098
21123
  // 宿主 inventory 权威标记:已有自定义画面时同步进本地记忆(换机/清配置后恢复)。
21099
21124
  if (w.hasCustomFrame) {
21100
21125
  const cf = Object.assign({}, selection.customFrames || {});
package/lib/features.js CHANGED
@@ -37,6 +37,7 @@ export const FEATURES = [
37
37
  role: '本机/上传/站点三源合并成壁纸库;着色、图层、播放与轮换由客户端接管',
38
38
  host: [
39
39
  'lib/routes/inventory.js',
40
+ 'lib/routes/site-media.js',
40
41
  'lib/routes/media-bytes.js',
41
42
  'lib/routes/media-derived.js',
42
43
  'lib/routes/scene-frame.js',
package/lib/index.js CHANGED
@@ -86,6 +86,7 @@ import { registerOcclusionRoutes } from './routes/occlusion.js';
86
86
  import { registerSceneFrameRoutes } from './routes/scene-frame.js';
87
87
  import { registerMediaDerivedRoutes } from './routes/media-derived.js';
88
88
  import { registerMediaBytesRoutes } from './routes/media-bytes.js';
89
+ import { registerSiteMediaRoutes } from './routes/site-media.js';
89
90
  import { registerWeAssetsRoutes } from './routes/we-assets.js';
90
91
  import { registerSceneMediaRoutes } from './routes/scene-media.js';
91
92
  import { registerSceneServeRoutes } from './routes/scene-serve.js';
@@ -325,6 +326,12 @@ export function apply(ctx) {
325
326
  ensureImagePreview,
326
327
  });
327
328
 
329
+ // 3b. 站点远程媒体的**本机缓存代理** → lib/routes/site-media.js。
330
+ // 为什么单开一族而不是塞进 /media:那条是**本机字节直出**(token → 磁盘),
331
+ // 这条是"上游 → 磁盘 → 客户端"的三方转发,失败语义完全不同(一切失败 302 直连)。
332
+ // ⚠️ 必须早于任何 `/site-*` 前缀族:本路由是 exact,不受顺序影响,但保持就近声明。
333
+ registerSiteMediaRoutes(webServer, { disposers, base: BASE, serveFile, log });
334
+
328
335
  // 3c-3b/3c-5. 场景帧服务族路由(实时抓帧缓存 / 自定义画面)→ lib/routes/scene-frame.js。
329
336
  // 三条注册与它们的依赖都在那个文件里;这里只声明它的依赖。
330
337
  registerSceneFrameRoutes(webServer, {
@@ -92,6 +92,7 @@ export const FEATURE_ROUTE_FAMILIES = [
92
92
  export const INLINE_ROUTE_FAMILIES = [
93
93
  // 每条写「族 id + 门面里的注册函数名」:两者**不总同形**(presets → registerGlassPresetsRoutes),
94
94
  // 靠名字猜会在守卫里制造假红。迁移一个族 = 在 FEATURE_ROUTE_FAMILIES 加一条 + 删这里一行。
95
+ { id: 'site-media', fn: 'registerSiteMediaRoutes' },
95
96
  { id: 'media-bytes', fn: 'registerMediaBytesRoutes' },
96
97
  { id: 'media-derived', fn: 'registerMediaDerivedRoutes' },
97
98
  { id: 'scene-frame', fn: 'registerSceneFrameRoutes' },
@@ -280,7 +280,6 @@ export function registerBootRoutes(webServer, c) {
280
280
  fadeMs: boot.fadeMs,
281
281
  minShowMs: boot.minShowMs,
282
282
  maxHoldMs: boot.maxHoldMs,
283
- sound: boot.sound,
284
283
  clips,
285
284
  error: candidates.error,
286
285
  });
@@ -287,7 +287,13 @@ function foliaItemFromCatalog(it, base) {
287
287
  // 而是显示「工程包 · 暂不支持」的明确说明(而不是让浏览器报解码失败)。
288
288
  playable: type !== 'package',
289
289
  unsupportedReason: type === 'package' ? 'we-package' : null,
290
- media, // 绝对 URL
290
+ media, // 绝对 URL(原始站点地址;下载/兜底都用它)
291
+ // 播放走**本机缓存代理**:首次边下边播并落盘,之后同一张直接从磁盘读(切换不再等网络)。
292
+ // 尾部那一段只是"像文件名的占位"(客户端拿 `url.split('/').pop()` 当 token 去问 /media-info,
293
+ // 查不到即"未知源",不会报错)。代理的任何失败都会 302 回 `media` ⇒ 与直连等价。
294
+ mediaProxy: type === 'package' ? null
295
+ : '/folia/site-media/' + encodeURIComponent(String(media).split('/').pop() || 'media')
296
+ + '?u=' + encodeURIComponent(String(media)),
291
297
  mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
292
298
  // 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
293
299
  preview: sitePreview || thumb || null,
@@ -0,0 +1,305 @@
1
+ /**
2
+ * routes/site-media.js — **站点远程媒体的本机缓存代理**:`/folia/site-media?u=<encoded>`。
3
+ *
4
+ * 为什么需要它:站点壁纸是 8~29 MB 的 4K 文件,浏览器每次都从网络重拉。切换到同一张时
5
+ * 又是从零开始 —— 在弱网机器上这就是"点了要等好几秒"。缓存代理把第一次的字节留在本机,
6
+ * 第二次起走磁盘(与"本机壁纸"同一条快路)。
7
+ *
8
+ * ## 设计红线:**缓存是加速器,永远不是必经路径**
9
+ *
10
+ * 任何一环不可用(目录不可写 / 超容量 / 上游 401、404、超时 / 非白名单),一律
11
+ * **302 到原始 URL**。浏览器自己会跟过去 —— 用户看到的只是"没加速",而不是"看不了"。
12
+ * 这条不是防御性编程的洁癖:别人的机器可能有只读盘、C 盘快满、公司代理、跨境弱网,
13
+ * 我们在自己机器上永远测不到这些组合。
14
+ *
15
+ * ## 五条防线(每条都有守卫,见 test/site-media-http.mjs)
16
+ *
17
+ * ① 白名单 —— `u` 必须是 http(s)、host 与站点基址一致、路径落在 `/media/`、`/assets/`。
18
+ * 否则本路由就是**开放代理**(SSRF / 带宽滥用)。
19
+ * ② 容量 + LRU —— 默认 2 GiB(`DSH_FOLIA_SITE_CACHE_MB` 可覆盖);按 atime 淘汰。
20
+ * ③ 可写性 —— 首次使用时试写哨兵;失败即 `disabled`,此后全部 302。
21
+ * ④ 续传 —— `.part` + `.part.json`(url/etag/size);同 URL 再取时用 Range 续,从头不再是默认。
22
+ * ⑤ 超时 + 并发 —— 首字节 8s 不到就放弃直连;同时最多 3 条上游大文件流(弱网不能自己打满)。
23
+ *
24
+ * 键 = `sha256(url)[:32] + 原扩展名`:站点重传会换 URL(文件名里带内容哈希),
25
+ * 换 URL 即换键 ⇒ 天然不会把旧内容当成新内容。
26
+ *
27
+ * 注入契约(ADR-0013 D12):`DEPS` 逐键由 `lib/index.js` 给到。
28
+ */
29
+ import { createHash } from 'node:crypto';
30
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
31
+ import { join, extname } from 'node:path';
32
+ import { homedir } from 'node:os';
33
+
34
+ export const DEPS = ['disposers', 'base', 'serveFile', 'log'];
35
+
36
+ const DEFAULT_CAP_MB = 2048;
37
+ const FIRST_BYTE_TIMEOUT_MS = 8000;
38
+ const MAX_UPSTREAM_CONCURRENCY = 3;
39
+ const EXT_OK = new Set(['.mp4', '.webm', '.mov', '.m4v', '.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif']);
40
+
41
+ /** 站点基址(与其它路由同一口径:`DSH_FOLIA_SITE=off` 关站,缺省官方站点)。 */
42
+ function siteBase() {
43
+ const raw = process.env.DSH_FOLIA_SITE;
44
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
45
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : 'https://folia-5ve.pages.dev';
46
+ return base.replace(/\/+$/, '');
47
+ }
48
+
49
+ function dshHome() {
50
+ return process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
51
+ }
52
+
53
+ function cacheRoot() { return join(dshHome(), 'cache', 'site-media'); }
54
+
55
+ function capBytes() {
56
+ const mb = Number(process.env.DSH_FOLIA_SITE_CACHE_MB || DEFAULT_CAP_MB);
57
+ return (Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_CAP_MB) * 1024 * 1024;
58
+ }
59
+
60
+ /** 白名单判定:同 host + 只认媒体/静态资源前缀。返回 null 表示拒绝(调用点回 400)。 */
61
+ function allowedTarget(raw, base) {
62
+ let u;
63
+ try { u = new URL(String(raw || '')); } catch { return null; }
64
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
65
+ let siteOrigin;
66
+ try { siteOrigin = new URL(base); } catch { return null; }
67
+ if (u.hostname !== siteOrigin.hostname) return null;
68
+ if (!/^\/(media|assets)\//.test(u.pathname)) return null;
69
+ return u;
70
+ }
71
+
72
+ function keyFor(u) {
73
+ const h = createHash('sha256').update(u.toString()).digest('hex').slice(0, 32);
74
+ const ext = extname(u.pathname).toLowerCase();
75
+ return h + (EXT_OK.has(ext) ? ext : '.bin');
76
+ }
77
+
78
+ /** 目录总量与最旧条目(LRU 用;只看本目录的常规文件,跳过分片)。 */
79
+ function scanCache(dir) {
80
+ let total = 0;
81
+ const entries = [];
82
+ let names;
83
+ try { names = readdirSync(dir); } catch { return { total, entries }; }
84
+ for (const name of names) {
85
+ if (name.endsWith('.part') || name.endsWith('.part.json')) continue;
86
+ const abs = join(dir, name);
87
+ try {
88
+ const st = statSync(abs);
89
+ if (!st.isFile()) continue;
90
+ total += st.size;
91
+ entries.push({ abs, size: st.size, at: st.atimeMs || st.mtimeMs || 0 });
92
+ } catch { /* 竞态删除:跳过 */ }
93
+ }
94
+ entries.sort((a, b) => a.at - b.at);
95
+ return { total, entries };
96
+ }
97
+
98
+ /** 超出上限就按 atime 淘汰最旧的,直到回到上限内。返回删掉的条数。 */
99
+ function evictToCap(dir, log) {
100
+ const cap = capBytes();
101
+ let { total, entries } = scanCache(dir);
102
+ let removed = 0;
103
+ for (const e of entries) {
104
+ if (total <= cap) break;
105
+ try { rmSync(e.abs, { force: true }); total -= e.size; removed += 1; } catch { /* ignore */ }
106
+ }
107
+ if (removed && typeof log === 'function') { try { log('site-media: LRU 淘汰 ' + removed + ' 条,剩余 ' + Math.round(total / 1048576) + ' MB'); } catch { /* ignore */ } }
108
+ return removed;
109
+ }
110
+
111
+ /** 首次使用探一次可写性;失败即整族降级为"全部 302 直连"。 */
112
+ function probeWritable(dir) {
113
+ try {
114
+ mkdirSync(dir, { recursive: true });
115
+ const sentinel = join(dir, '.writable');
116
+ writeFileSync(sentinel, '1');
117
+ rmSync(sentinel, { force: true });
118
+ return true;
119
+ } catch { return false; }
120
+ }
121
+
122
+ function touch(abs) {
123
+ try { const now = new Date(); utimesSync(abs, now, now); } catch { /* ignore */ }
124
+ }
125
+
126
+ function readPartMeta(part) {
127
+ try { return JSON.parse(readFileSync(part + '.json', 'utf8')); } catch { return null; }
128
+ }
129
+
130
+ export function registerSiteMediaRoutes(webServer, c) {
131
+ const { disposers, base, serveFile, log } = c;
132
+ const BASE = base;
133
+ /** key → 在途下载的 Promise(去重:同一 URL 并发只发一次上游)。 */
134
+ const inflight = new Map();
135
+ /** 在途上游大文件流的并发闸门。 */
136
+ let active = 0;
137
+ const waiters = [];
138
+ /** 可写性按**目录**记忆:DSH_HOME 可能在运行期变(测试/多 profile),不能一次性缓存全局。 */
139
+ const writableByDir = new Map();
140
+
141
+ const ensureCache = (dir) => {
142
+ const known = writableByDir.get(dir);
143
+ if (known !== undefined) return known;
144
+ const ok = probeWritable(dir);
145
+ writableByDir.set(dir, ok);
146
+ if (!ok && typeof log === 'function') {
147
+ try { log('site-media: 缓存目录不可写,已降级为直连(功能不受影响,只是不加速)'); } catch { /* ignore */ }
148
+ }
149
+ return ok;
150
+ };
151
+
152
+ const acquire = () => new Promise((resolve) => {
153
+ if (active < MAX_UPSTREAM_CONCURRENCY) { active += 1; resolve(); return; }
154
+ waiters.push(() => { active += 1; resolve(); });
155
+ });
156
+ const release = () => {
157
+ active = Math.max(0, active - 1);
158
+ const next = waiters.shift();
159
+ if (next) next();
160
+ };
161
+
162
+ /** 302 直连:缓存层所有失败路径的唯一出口。 */
163
+ function redirect(res, target) {
164
+ try {
165
+ res.statusCode = 302;
166
+ res.setHeader('location', target.toString());
167
+ res.setHeader('cache-control', 'no-store');
168
+ res.end();
169
+ } catch { try { res.end(); } catch { /* ignore */ } }
170
+ }
171
+
172
+ /**
173
+ * 把上游字节边转发边落盘。成功返回 true(文件已在盘上),失败返回 false(调用点 302)。
174
+ * 已经给客户端发过头之后失败:只能 destroy 响应(这一条由 response 自己兜)。
175
+ */
176
+ async function download(u, key, req, res, headOnly) {
177
+ const dir = cacheRoot();
178
+ const abs = join(dir, key);
179
+ const part = abs + '.part';
180
+ const meta = readPartMeta(part);
181
+ let from = 0;
182
+ try { from = meta && meta.url === u.toString() && existsSync(part) ? statSync(part).size : 0; } catch { from = 0; }
183
+
184
+ await acquire();
185
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
186
+ let timer = null;
187
+ try {
188
+ if (typeof setTimeout === 'function') {
189
+ timer = setTimeout(() => { try { ctrl && ctrl.abort(); } catch { /* ignore */ } }, FIRST_BYTE_TIMEOUT_MS);
190
+ }
191
+ const headers = { accept: '*/*', 'user-agent': 'dsh-folia/site-media' };
192
+ if (from > 0) headers.range = 'bytes=' + from + '-';
193
+ const r = await fetch(u.toString(), { headers, signal: ctrl ? ctrl.signal : undefined, redirect: 'follow' });
194
+ if (timer) { clearTimeout(timer); timer = null; }
195
+ if (!r.ok && r.status !== 206) return false;
196
+ const ctype = r.headers.get('content-type') || 'application/octet-stream';
197
+ // 续传时上游不支持 Range(回 200)⇒ 从头来过,别把两段接在一起。
198
+ const startAt = r.status === 206 ? from : 0;
199
+ if (startAt === 0 && existsSync(part)) { try { rmSync(part, { force: true }); } catch { /* ignore */ } }
200
+ const total = Number(r.headers.get('content-length') || 0) + startAt;
201
+ // ⚠️ **先落盘、后发头**:写盘失败(只读盘/满盘)必须还能 302 直连。
202
+ // 旧顺序是先 setHeader 再写文件 —— 一旦写失败就已经 headersSent,只能 destroy,
203
+ // 用户看到的是"白屏 + 200"而不是回退直连(实测踩过)。
204
+ let out;
205
+ try {
206
+ writeFileSync(part + '.json', JSON.stringify({
207
+ url: u.toString(), etag: r.headers.get('etag') || '', size: total || null, at: Date.now(),
208
+ }));
209
+ out = createWriteStream(part, { flags: startAt > 0 ? 'a' : 'w' });
210
+ } catch {
211
+ try { if (r.body && typeof r.body.cancel === 'function') r.body.cancel(); } catch { /* ignore */ }
212
+ return false;
213
+ }
214
+ if (!headOnly) {
215
+ res.statusCode = r.status === 206 ? 206 : 200;
216
+ res.setHeader('content-type', ctype);
217
+ res.setHeader('accept-ranges', 'bytes');
218
+ if (total > 0) res.setHeader('content-length', String(total - startAt));
219
+ if (r.status === 206) {
220
+ const cr = r.headers.get('content-range');
221
+ if (cr) res.setHeader('content-range', cr);
222
+ }
223
+ res.setHeader('cache-control', 'private, max-age=3600');
224
+ }
225
+ const reader = r.body && typeof r.body.getReader === 'function' ? r.body.getReader() : null;
226
+ let received = startAt;
227
+ let wrote = false;
228
+ if (reader) {
229
+ for (;;) {
230
+ const { done, value } = await reader.read();
231
+ if (done) break;
232
+ const buf = Buffer.from(value);
233
+ received += buf.length;
234
+ if (!out.write(buf)) await new Promise((resolveDrain) => { out.once('drain', resolveDrain); });
235
+ if (!headOnly && !res.writableEnded) { try { res.write(buf); } catch { /* 客户端走了,继续落盘 */ } }
236
+ wrote = true;
237
+ }
238
+ }
239
+ await new Promise((resolveEnd) => { out.end(resolveEnd); });
240
+ if (!wrote && startAt === 0) { try { rmSync(part, { force: true }); } catch { /* ignore */ } return false; }
241
+ renameSync(part, abs);
242
+ try { rmSync(part + '.json', { force: true }); } catch { /* ignore */ }
243
+ if (!headOnly && !res.writableEnded) res.end();
244
+ else if (!res.writableEnded) res.end();
245
+ touch(abs);
246
+ evictToCap(dir, log);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ } finally {
251
+ if (timer) clearTimeout(timer);
252
+ release();
253
+ }
254
+ }
255
+
256
+ disposers.push(webServer.register({
257
+ kind: 'prefix',
258
+ // 路径尾部只是给客户端留一个"像文件名"的段(客户端按 `url.split('/').pop()` 取 token 做
259
+ // `/media-info` 查询;查不到就是"未知源",不会报错)。真正的目标在 `?u=` 里。
260
+ path: `${BASE}/site-media`,
261
+ handler: async (req, res) => {
262
+ const method = (req.method || 'GET').toUpperCase();
263
+ if (method !== 'GET' && method !== 'HEAD') {
264
+ try { res.statusCode = 405; res.setHeader('allow', 'GET, HEAD'); res.end(); } catch { /* ignore */ }
265
+ return;
266
+ }
267
+ const base0 = siteBase();
268
+ if (!base0) { try { res.statusCode = 404; res.end(); } catch { /* ignore */ } return; }
269
+ let raw = '';
270
+ try { raw = new URL(req.url || '', 'http://x').searchParams.get('u') || ''; } catch { raw = ''; }
271
+ const u = allowedTarget(raw, base0);
272
+ if (!u) { try { res.statusCode = 400; res.end(); } catch { /* ignore */ } return; }
273
+
274
+ const headOnly = method === 'HEAD';
275
+ const dir = cacheRoot();
276
+ const key = keyFor(u);
277
+ const abs = join(dir, key);
278
+
279
+ if (ensureCache(dir) && existsSync(abs)) {
280
+ touch(abs);
281
+ serveFile(abs, req, res, headOnly);
282
+ return;
283
+ }
284
+ // 缓存不可用 / 未命中:交给 302 或边下边播
285
+ if (!ensureCache(dir) || headOnly) { redirect(res, u); return; }
286
+ const running = inflight.get(key);
287
+ if (running) {
288
+ // 同一 URL 已在下载:等它落盘(上限 60s),成功就发文件,否则直连。
289
+ const ok = await Promise.race([running, new Promise((r) => setTimeout(() => r(false), 60000))]);
290
+ if (ok && existsSync(abs)) { touch(abs); serveFile(abs, req, res, headOnly); return; }
291
+ redirect(res, u);
292
+ return;
293
+ }
294
+ const task = download(u, key, req, res, headOnly);
295
+ inflight.set(key, task);
296
+ let ok = false;
297
+ try { ok = await task; } finally { inflight.delete(key); }
298
+ if (!ok) {
299
+ // 下载失败:**必须**回退到直连(缓存是加速器,不是必经路径)
300
+ if (!res.headersSent) redirect(res, u);
301
+ else { try { res.destroy(); } catch { /* ignore */ } }
302
+ }
303
+ },
304
+ }));
305
+ }
@@ -83,6 +83,23 @@ function siteBase() {
83
83
  * 判据用**它自己的 catalog 路由**:该路由不需要鉴权(实测 200),404 就是没装。
84
84
  */
85
85
  const skinCenterProbe = { at: 0, state: 'unknown', detail: '' };
86
+
87
+ /**
88
+ * 皮肤中心是不是被**用户自己在插件市场里禁用了**。
89
+ *
90
+ * 为什么要单独读:它被禁用时 DSH 的鉴权中间件先于路由回答 401,和"需要会话"长得一模一样,
91
+ * 于是页签只能写出"浏览器侧也被鉴权挡住"这种既对又没用的话(实测被用户当成插件故障)。
92
+ * 市场的 `.dsh-market/state.json` 里有 `disabled` 数组 —— 读它是**只读事实**,比猜准得多。
93
+ */
94
+ function readMarketDisabled() {
95
+ try {
96
+ const p = join(dshHome(), 'profiles', 'web', '.dsh-market', 'state.json');
97
+ const j = JSON.parse(readFileSync(p, 'utf8'));
98
+ const list = Array.isArray(j && j.disabled) ? j.disabled : [];
99
+ return { known: true, skinCenterDisabled: list.includes('@linxin666/dsh-client-ui-skin-center') };
100
+ } catch { return { known: false, skinCenterDisabled: false }; }
101
+ }
102
+
86
103
  async function probeSkinCenter(hostHeader, force) {
87
104
  const now = Date.now();
88
105
  if (!force && skinCenterProbe.at && now - skinCenterProbe.at < 30000) return skinCenterProbe.state;
@@ -144,11 +161,26 @@ async function fetchSiteSkins(force) {
144
161
  mime: String(it.mime || ''),
145
162
  url: String(it.url || ''),
146
163
  thumbUrl: it.thumbUrl ? String(it.thumbUrl) : '',
147
- tags: (() => { try { return JSON.parse(it.tags || '[]'); } catch { return []; } })(),
164
+ // 站点现在把 tags 直接给**数组**(旧版给的是字符串化 JSON)。两种都收:
165
+ // 只认字符串会让「动态」这类标记静默丢失(实测:线上返回 ["皮肤","catppuccin",…])。
166
+ tags: (() => {
167
+ const raw = it.tags;
168
+ if (Array.isArray(raw)) return raw.map((t) => String(t));
169
+ try { const parsed = JSON.parse(raw || '[]'); return Array.isArray(parsed) ? parsed.map((t) => String(t)) : []; } catch { return []; }
170
+ })(),
148
171
  }));
149
172
  siteCache.error = null;
150
173
  } catch (e) {
151
- siteCache.error = (e && e.message) || 'fetch-failed';
174
+ // 把站点的**真实原因**带出来:站点整站上锁时只回 401 + `{"error":{"code":"site_locked"}}`,
175
+ // 只显示 "http-401" 会让人以为是插件/网络问题(实测就栽在这条上)。
176
+ const msg = (e && e.message) || 'fetch-failed';
177
+ const status = /^http-(\d+)$/.exec(msg);
178
+ if (status) {
179
+ const code = Number(status[1]);
180
+ siteCache.error = (code === 401 || code === 403) ? 'site-locked' : msg;
181
+ } else {
182
+ siteCache.error = msg;
183
+ }
152
184
  siteCache.items = siteCache.items || [];
153
185
  }
154
186
  siteCache.at = now;
@@ -320,16 +352,19 @@ export function registerSkinsRoutes(webServer, c) {
320
352
  installedKeys.add(normSkinId(s.id));
321
353
  installedKeys.add(normSkinId(s.dirId));
322
354
  }
355
+ const market = readMarketDisabled();
323
356
  sendJson(res, 200, {
324
357
  ok: true,
325
358
  skinCenter,
359
+ // 用户在插件市场里**手动禁用**它 ⇒ 这是我们能给出的最准确的原因(比 401 含糊其辞好)
360
+ skinCenterDisabled: market.skinCenterDisabled,
361
+ skinCenterMarketKnown: market.known,
326
362
  skinCenterDetail: skinCenterProbe.detail || '',
327
- // 皮肤中心被"保持为 off"时(peer 版本不匹配等),用户需要知道去改什么。
328
- // 这里不动它,只把"它为什么可能不在"说清楚:我们钉的是 0.4.8(peer dsh >= 0.2.1-rc.2),
329
- // 0.4.9+ 要求 rc.3,装了更高版本就会被市场关掉。
363
+ // 版本线索:我们随身携带的是 0.4.12(engines 要求 dsh >= 0.2.1-rc.3)。
364
+ // 低于该内核的机器会被市场 keep off —— 页签据此告诉用户"要么升级桌面端、要么别指望它"。
330
365
  skinCenterHint: skinCenter === 'present'
331
366
  ? ''
332
- : 'skin-center-0.4.8',
367
+ : 'skin-center-0.4.12',
333
368
  siteBase: siteBase(),
334
369
  siteError: site.error,
335
370
  installed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.13.0",
3
+ "version": "4.14.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -193,6 +193,7 @@
193
193
  }
194
194
  },
195
195
  "dependencies": {
196
+ "@linxin666/dsh-client-ui-skin-center": "0.4.12",
196
197
  "webwallgl": "^3.0.0",
197
198
  "x11": "^4.3.1"
198
199
  },