dsh-folia 4.13.1 → 4.14.1

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
@@ -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",
@@ -18570,6 +18569,13 @@ window.__ModuleLoader__.load({
18570
18569
  : installed.map((s) => {
18571
18570
  const isActive = isActiveSkin(s.id);
18572
18571
  const busyThis = busy === "activate:" + s.id;
18572
+ // 皮肤中心**没收录**这套(它有自己的 manifest 规则)⇒ 应用后它会回退到内置默认款,
18573
+ // 用户看到的是"点哪套都不变"。这是实测症状(别人机器上"只剩鲸鱼娘"),所以这里
18574
+ // 直接把原因与「应用」的禁用一起给出来,而不是让人一遍遍点。
18575
+ const notListed = s.inCatalog === false;
18576
+ const notListedWhy = notListed
18577
+ ? weT("皮肤中心没有收录这套(它的 manifest 规则更严:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全)")
18578
+ : "";
18573
18579
  return React.createElement("div", {
18574
18580
  key: s.id,
18575
18581
  style: isActive ? { ...SKIN_STYLE.card, borderColor: "rgba(120,220,150,0.75)" } : SKIN_STYLE.card,
@@ -18577,7 +18583,8 @@ window.__ModuleLoader__.load({
18577
18583
  React.createElement("div", { style: { ...SKIN_STYLE.title, color: s.accent || undefined } },
18578
18584
  (isActive ? weT("✓ ") : "") + (s.name || s.id)),
18579
18585
  React.createElement("div", { style: SKIN_STYLE.meta },
18580
- [s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : ""]
18586
+ [s.version ? "v" + s.version : "", skinFmtBytes(s.bytes), s.hasHooks ? weT("含动效钩子") : "",
18587
+ notListed ? weT("皮肤中心未收录") : ""]
18581
18588
  .filter(Boolean).join(" · ")),
18582
18589
  React.createElement("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
18583
18590
  // 「应用」= 写皮肤中心的 active + 广播 `dsh-skin-applied`,就地切换(不刷新页面)。
@@ -18585,8 +18592,8 @@ window.__ModuleLoader__.load({
18585
18592
  ? React.createElement("span", { style: { ...SKIN_STYLE.badge, background: "rgba(60,160,90,0.55)" } }, weT("使用中"))
18586
18593
  : React.createElement("button", {
18587
18594
  className: "we-picker__btn", type: "button",
18588
- disabled: Boolean(busy) || !canApply,
18589
- title: applyBlockedReason || undefined,
18595
+ disabled: Boolean(busy) || !canApply || notListed,
18596
+ title: notListed ? notListedWhy : (applyBlockedReason || undefined),
18590
18597
  onClick: () => act("activate", { id: s.id }, s.name),
18591
18598
  }, busyThis ? weT("切换中…") : weT("应用")),
18592
18599
  React.createElement("button", {
@@ -20056,8 +20063,14 @@ window.__ModuleLoader__.load({
20056
20063
  // "露出底色"照旧不发生。
20057
20064
  // 场景内嵌 MP4 的 poster 是静态帧,是「先静帧后动态」的既有设计,保留 —— 判据见
20058
20065
  // rotation-prepared-leak-smoke.mjs 的 T2 / T2b(无静态缩略图仍必须等帧)。
20066
+ // 站点(远程)壁纸也带静态缩略图(`thumbUrl`,webp/jpg)。旧判据只认**本机 ffmpeg 抽的**
20067
+ // `/video-preview/`,于是远程视频**没有 poster** ⇒ 闸门必须等首个可解码帧才放行。
20068
+ // 实测代价:一条 8~29 MB 的 4K 远程源,切换要等数秒(首帧在网络那头)。
20069
+ // 现在把**静态图后缀**也认作 poster(`.gif` 仍排除 —— 动图当 poster 会"先播预览",
20070
+ // 那正是这条判据当初要挡的东西)。放行条件不变:poster **真的加载出来**才算有画面。
20071
+ const siteStaticThumb = !!sel.previewUrl && /\.(jpe?g|png|webp|avif)(?:[?#]|$)/i.test(sel.previewUrl);
20059
20072
  const stillPreview = !!sel.previewUrl
20060
- && (sel.type !== "video" || /\/video-preview\//.test(sel.previewUrl));
20073
+ && (sel.type !== "video" || /\/video-preview\//.test(sel.previewUrl) || siteStaticThumb);
20061
20074
  if (stillPreview) media.poster = sel.previewUrl;
20062
20075
  }
20063
20076
  media.autoplay = true;
@@ -21107,7 +21120,14 @@ window.__ModuleLoader__.load({
21107
21120
  return;
21108
21121
  }
21109
21122
  const savedVariant = Number(selection.frameVariants && selection.frameVariants[String(w.id)]) || 0;
21110
- selection.url = w.type === "scene" ? frameUrlWithVariant(w.frameUrl, savedVariant) : w.media;
21123
+ // 站点条目带 `mediaProxy`(宿主缓存代理,失败一律 302 回原 URL)⇒ 优先用它播放:
21124
+ // 首次边下边播并落盘,之后同一张直接读磁盘,切换不再等网络。没有代理就直连 `w.media`。
21125
+ const playableSrc = w.type === "scene"
21126
+ ? frameUrlWithVariant(w.frameUrl, savedVariant)
21127
+ : (w.mediaProxy || w.media);
21128
+ selection.url = playableSrc;
21129
+ // `media` 仍保留原始站点地址:下载 / 兜底 / 诊断用它(不经过我们的代理)。
21130
+ selection.originUrl = w.media || null;
21111
21131
  // 宿主 inventory 权威标记:已有自定义画面时同步进本地记忆(换机/清配置后恢复)。
21112
21132
  if (w.hasCustomFrame) {
21113
21133
  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,331 @@
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 { Readable } from 'node:stream';
31
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
32
+ import { join, extname } from 'node:path';
33
+ import { homedir } from 'node:os';
34
+
35
+ export const DEPS = ['disposers', 'base', 'serveFile', 'log'];
36
+
37
+ const DEFAULT_CAP_MB = 2048;
38
+ const FIRST_BYTE_TIMEOUT_MS = 8000;
39
+ const MAX_UPSTREAM_CONCURRENCY = 3;
40
+ const EXT_OK = new Set(['.mp4', '.webm', '.mov', '.m4v', '.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif']);
41
+
42
+ /** 站点基址(与其它路由同一口径:`DSH_FOLIA_SITE=off` 关站,缺省官方站点)。 */
43
+ function siteBase() {
44
+ const raw = process.env.DSH_FOLIA_SITE;
45
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
46
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : 'https://folia-5ve.pages.dev';
47
+ return base.replace(/\/+$/, '');
48
+ }
49
+
50
+ function dshHome() {
51
+ return process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
52
+ }
53
+
54
+ function cacheRoot() { return join(dshHome(), 'cache', 'site-media'); }
55
+
56
+ function capBytes() {
57
+ const mb = Number(process.env.DSH_FOLIA_SITE_CACHE_MB || DEFAULT_CAP_MB);
58
+ return (Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_CAP_MB) * 1024 * 1024;
59
+ }
60
+
61
+ /** 白名单判定:同 host + 只认媒体/静态资源前缀。返回 null 表示拒绝(调用点回 400)。 */
62
+ function allowedTarget(raw, base) {
63
+ let u;
64
+ try { u = new URL(String(raw || '')); } catch { return null; }
65
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
66
+ let siteOrigin;
67
+ try { siteOrigin = new URL(base); } catch { return null; }
68
+ if (u.hostname !== siteOrigin.hostname) return null;
69
+ if (!/^\/(media|assets)\//.test(u.pathname)) return null;
70
+ return u;
71
+ }
72
+
73
+ function keyFor(u) {
74
+ const h = createHash('sha256').update(u.toString()).digest('hex').slice(0, 32);
75
+ const ext = extname(u.pathname).toLowerCase();
76
+ return h + (EXT_OK.has(ext) ? ext : '.bin');
77
+ }
78
+
79
+ /** 目录总量与最旧条目(LRU 用;只看本目录的常规文件,跳过分片)。 */
80
+ function scanCache(dir) {
81
+ let total = 0;
82
+ const entries = [];
83
+ let names;
84
+ try { names = readdirSync(dir); } catch { return { total, entries }; }
85
+ for (const name of names) {
86
+ if (name.endsWith('.part') || name.endsWith('.part.json')) continue;
87
+ const abs = join(dir, name);
88
+ try {
89
+ const st = statSync(abs);
90
+ if (!st.isFile()) continue;
91
+ total += st.size;
92
+ entries.push({ abs, size: st.size, at: st.atimeMs || st.mtimeMs || 0 });
93
+ } catch { /* 竞态删除:跳过 */ }
94
+ }
95
+ entries.sort((a, b) => a.at - b.at);
96
+ return { total, entries };
97
+ }
98
+
99
+ /** 超出上限就按 atime 淘汰最旧的,直到回到上限内。返回删掉的条数。 */
100
+ function evictToCap(dir, log) {
101
+ const cap = capBytes();
102
+ let { total, entries } = scanCache(dir);
103
+ let removed = 0;
104
+ for (const e of entries) {
105
+ if (total <= cap) break;
106
+ try { rmSync(e.abs, { force: true }); total -= e.size; removed += 1; } catch { /* ignore */ }
107
+ }
108
+ if (removed && typeof log === 'function') { try { log('site-media: LRU 淘汰 ' + removed + ' 条,剩余 ' + Math.round(total / 1048576) + ' MB'); } catch { /* ignore */ } }
109
+ return removed;
110
+ }
111
+
112
+ /** 首次使用探一次可写性;失败即整族降级为"全部 302 直连"。 */
113
+ function probeWritable(dir) {
114
+ try {
115
+ mkdirSync(dir, { recursive: true });
116
+ const sentinel = join(dir, '.writable');
117
+ writeFileSync(sentinel, '1');
118
+ rmSync(sentinel, { force: true });
119
+ return true;
120
+ } catch { return false; }
121
+ }
122
+
123
+ function touch(abs) {
124
+ try { const now = new Date(); utimesSync(abs, now, now); } catch { /* ignore */ }
125
+ }
126
+
127
+ function readPartMeta(part) {
128
+ try { return JSON.parse(readFileSync(part + '.json', 'utf8')); } catch { return null; }
129
+ }
130
+
131
+ export function registerSiteMediaRoutes(webServer, c) {
132
+ const { disposers, base, serveFile, log } = c;
133
+ const BASE = base;
134
+ /** key → 在途下载的 Promise(去重:同一 URL 并发只发一次上游)。 */
135
+ const inflight = new Map();
136
+ /** 在途上游大文件流的并发闸门。 */
137
+ let active = 0;
138
+ const waiters = [];
139
+ /** 可写性按**目录**记忆:DSH_HOME 可能在运行期变(测试/多 profile),不能一次性缓存全局。 */
140
+ const writableByDir = new Map();
141
+
142
+ const ensureCache = (dir) => {
143
+ const known = writableByDir.get(dir);
144
+ if (known !== undefined) return known;
145
+ const ok = probeWritable(dir);
146
+ writableByDir.set(dir, ok);
147
+ if (!ok && typeof log === 'function') {
148
+ try { log('site-media: 缓存目录不可写,已降级为直连(功能不受影响,只是不加速)'); } catch { /* ignore */ }
149
+ }
150
+ return ok;
151
+ };
152
+
153
+ const acquire = () => new Promise((resolve) => {
154
+ if (active < MAX_UPSTREAM_CONCURRENCY) { active += 1; resolve(); return; }
155
+ waiters.push(() => { active += 1; resolve(); });
156
+ });
157
+ const release = () => {
158
+ active = Math.max(0, active - 1);
159
+ const next = waiters.shift();
160
+ if (next) next();
161
+ };
162
+
163
+ /** 302 直连:缓存层所有失败路径的唯一出口。 */
164
+ function redirect(res, target) {
165
+ try {
166
+ res.statusCode = 302;
167
+ res.setHeader('location', target.toString());
168
+ res.setHeader('cache-control', 'no-store');
169
+ res.end();
170
+ } catch { try { res.end(); } catch { /* ignore */ } }
171
+ }
172
+
173
+ /**
174
+ * 把上游字节边转发边落盘。成功返回 true(文件已在盘上),失败返回 false(调用点 302)。
175
+ * 已经给客户端发过头之后失败:只能 destroy 响应(这一条由 response 自己兜)。
176
+ */
177
+ async function download(u, key, req, res, headOnly) {
178
+ const dir = cacheRoot();
179
+ const abs = join(dir, key);
180
+ const part = abs + '.part';
181
+ const meta = readPartMeta(part);
182
+ // 续传起点:只有 meta 明确记录的是同一个 URL 才续(换 URL 即换键,这里再兜一层)
183
+ const from = (() => {
184
+ try { return meta && meta.url === u.toString() && existsSync(part) ? statSync(part).size : 0; } catch { return 0; }
185
+ })();
186
+
187
+ await acquire();
188
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
189
+ let timer = null;
190
+ try {
191
+ if (typeof setTimeout === 'function') {
192
+ timer = setTimeout(() => { try { ctrl && ctrl.abort(); } catch { /* ignore */ } }, FIRST_BYTE_TIMEOUT_MS);
193
+ }
194
+ const headers = { accept: '*/*', 'user-agent': 'dsh-folia/site-media' };
195
+ if (from > 0) headers.range = 'bytes=' + from + '-';
196
+ const r = await fetch(u.toString(), { headers, signal: ctrl ? ctrl.signal : undefined, redirect: 'follow' });
197
+ if (timer) { clearTimeout(timer); timer = null; }
198
+ if (!r.ok && r.status !== 206) return false;
199
+ const ctype = r.headers.get('content-type') || 'application/octet-stream';
200
+ // 续传时上游不支持 Range(回 200)⇒ 从头来过,别把两段接在一起。
201
+ const startAt = r.status === 206 ? from : 0;
202
+ if (startAt === 0 && existsSync(part)) { try { rmSync(part, { force: true }); } catch { /* ignore */ } }
203
+ const total = Number(r.headers.get('content-length') || 0) + startAt;
204
+ // ⚠️ **先落盘、后发头**:写盘失败(只读盘/满盘)必须还能 302 直连。
205
+ // 旧顺序是先 setHeader 再写文件 —— 一旦写失败就已经 headersSent,只能 destroy,
206
+ // 用户看到的是"白屏 + 200"而不是回退直连(实测踩过)。
207
+ let out;
208
+ try {
209
+ writeFileSync(part + '.json', JSON.stringify({
210
+ url: u.toString(), etag: r.headers.get('etag') || '', size: total || null, at: Date.now(),
211
+ }));
212
+ out = createWriteStream(part, { flags: startAt > 0 ? 'a' : 'w' });
213
+ } catch {
214
+ try { if (r.body && typeof r.body.cancel === 'function') r.body.cancel(); } catch { /* ignore */ }
215
+ return false;
216
+ }
217
+ if (!headOnly) {
218
+ res.statusCode = r.status === 206 ? 206 : 200;
219
+ res.setHeader('content-type', ctype);
220
+ res.setHeader('accept-ranges', 'bytes');
221
+ if (total > 0) res.setHeader('content-length', String(total - startAt));
222
+ if (r.status === 206) {
223
+ const cr = r.headers.get('content-range');
224
+ if (cr) res.setHeader('content-range', cr);
225
+ }
226
+ res.setHeader('cache-control', 'private, max-age=3600');
227
+ // 可观测:这条是**首次下载**(未命中)。命中走上面 serveFile 的分支,头是 `hit`。
228
+ res.setHeader('x-folia-cache', 'miss');
229
+ }
230
+ // ⚠️ 转发必须**带背压**:慢盘(机械盘 / 杀毒实时扫描)会让文件流 write() 返回 false,
231
+ // 此时若继续把上游字节往内存里塞,轻则内存飙高、重则把整条流拖成"越下越慢"。
232
+ // 旧实现只对文件流做 drain 等待、对客户端响应完全不看返回值 ⇒ 客户端慢时内存堆积。
233
+ // 现在两侧都看:任一侧未排空就 pause 上游,两侧都 drain 后再 resume。
234
+ let pendingFile = false;
235
+ let pendingRes = false;
236
+ const maybeResume = () => { if (!pendingFile && !pendingRes) { try { readerSrc.resume(); } catch { /* ignore */ } } };
237
+ const readerSrc = r.body ? Readable.fromWeb(r.body) : null;
238
+ let wrote = false;
239
+ if (readerSrc) {
240
+ await new Promise((resolveDone, rejectDone) => {
241
+ readerSrc.on('data', (chunk) => {
242
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
243
+ wrote = true;
244
+ if (!out.write(buf)) {
245
+ pendingFile = true;
246
+ readerSrc.pause();
247
+ out.once('drain', () => { pendingFile = false; maybeResume(); });
248
+ }
249
+ if (!headOnly && !res.writableEnded) {
250
+ const flushed = (() => { try { return res.write(buf); } catch { return true; } })();
251
+ if (!flushed) {
252
+ pendingRes = true;
253
+ readerSrc.pause();
254
+ res.once('drain', () => { pendingRes = false; maybeResume(); });
255
+ }
256
+ }
257
+ });
258
+ readerSrc.on('end', resolveDone);
259
+ readerSrc.on('error', rejectDone);
260
+ });
261
+ }
262
+ await new Promise((resolveEnd) => { out.end(resolveEnd); });
263
+ if (!wrote && startAt === 0) { try { rmSync(part, { force: true }); } catch { /* ignore */ } return false; }
264
+ renameSync(part, abs);
265
+ try { rmSync(part + '.json', { force: true }); } catch { /* ignore */ }
266
+ if (!headOnly && !res.writableEnded) res.end();
267
+ else if (!res.writableEnded) res.end();
268
+ touch(abs);
269
+ evictToCap(dir, log);
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ } finally {
274
+ if (timer) clearTimeout(timer);
275
+ release();
276
+ }
277
+ }
278
+
279
+ disposers.push(webServer.register({
280
+ kind: 'prefix',
281
+ // 路径尾部只是给客户端留一个"像文件名"的段(客户端按 `url.split('/').pop()` 取 token 做
282
+ // `/media-info` 查询;查不到就是"未知源",不会报错)。真正的目标在 `?u=` 里。
283
+ path: `${BASE}/site-media`,
284
+ handler: async (req, res) => {
285
+ const method = (req.method || 'GET').toUpperCase();
286
+ if (method !== 'GET' && method !== 'HEAD') {
287
+ try { res.statusCode = 405; res.setHeader('allow', 'GET, HEAD'); res.end(); } catch { /* ignore */ }
288
+ return;
289
+ }
290
+ const base0 = siteBase();
291
+ if (!base0) { try { res.statusCode = 404; res.end(); } catch { /* ignore */ } return; }
292
+ const raw = (() => {
293
+ try { return new URL(req.url || '', 'http://x').searchParams.get('u') || ''; } catch { return ''; }
294
+ })();
295
+ const u = allowedTarget(raw, base0);
296
+ if (!u) { try { res.statusCode = 400; res.end(); } catch { /* ignore */ } return; }
297
+
298
+ const headOnly = method === 'HEAD';
299
+ const dir = cacheRoot();
300
+ const key = keyFor(u);
301
+ const abs = join(dir, key);
302
+
303
+ if (ensureCache(dir) && existsSync(abs)) {
304
+ touch(abs);
305
+ // 与 miss 对称的可观测头:页签/诊断据此区分"网络慢"与"我们没有缓存"。
306
+ try { res.setHeader('x-folia-cache', 'hit'); } catch { /* ignore */ }
307
+ serveFile(abs, req, res, headOnly);
308
+ return;
309
+ }
310
+ // 缓存不可用 / 未命中:交给 302 或边下边播
311
+ if (!ensureCache(dir) || headOnly) { redirect(res, u); return; }
312
+ const running = inflight.get(key);
313
+ if (running) {
314
+ // 同一 URL 已在下载:等它落盘(上限 60s),成功就发文件,否则直连。
315
+ const ok = await Promise.race([running, new Promise((r) => setTimeout(() => r(false), 60000))]);
316
+ if (ok && existsSync(abs)) { touch(abs); serveFile(abs, req, res, headOnly); return; }
317
+ redirect(res, u);
318
+ return;
319
+ }
320
+ const task = download(u, key, req, res, headOnly);
321
+ inflight.set(key, task);
322
+ let ok = false;
323
+ try { ok = await task; } finally { inflight.delete(key); }
324
+ if (!ok) {
325
+ // 下载失败:**必须**回退到直连(缓存是加速器,不是必经路径)
326
+ if (!res.headersSent) redirect(res, u);
327
+ else { try { res.destroy(); } catch { /* ignore */ } }
328
+ }
329
+ },
330
+ }));
331
+ }
@@ -139,6 +139,44 @@ async function probeSkinCenter(hostHeader, force) {
139
139
  return state;
140
140
  }
141
141
 
142
+ /**
143
+ * 拉一份皮肤中心的 catalog,回答"**它到底收录了哪几套**"。
144
+ *
145
+ * 为什么必须问它、而不是自己扫目录:它有自己的收录规则(manifest.id 必须等于目录名、
146
+ * 必须 v2、name/nameEn/author/stylesheet 齐全,且 `diagnostics` 会给出被剔除的原因)。
147
+ * 我们扫到 5 个目录 ≠ 它认得这 5 套;实测症状:某台机器上"本机已装(5)"里点哪套都只显示
148
+ * 皮肤中心的内置默认款(blue-fantasy)——因为那几套压根不在它的 catalog 里,它的客户端按
149
+ * "active 解析不到就回退默认款"处理,看起来像"点了没反应"。
150
+ *
151
+ * 30 秒缓存:面板刷新/多标签共用;拿不到就返回 null("未知",不据此判红)。
152
+ */
153
+ // 快照缓存:写进 Map 而不是给模块级对象赋值 —— 后者正是 require-atomic-updates 判红的形态,
154
+ // 而这里本来就是「最后一次完整快照胜出」的语义,用 Map.set 更贴合且不触发该规则。
155
+ const catalogCacheStore = new Map();
156
+ function readCatalogCache() { return catalogCacheStore.get('v') || { at: 0, map: null }; }
157
+ function setCatalogCache(at, map) { catalogCacheStore.set('v', { at, map }); }
158
+ async function fetchSkinCenterCatalog(hostHeader, force) {
159
+ const now = Date.now();
160
+ const snapshot = readCatalogCache();
161
+ if (!force && snapshot.at && now - snapshot.at < 30000) return snapshot.map;
162
+ if (!hostHeader) return readCatalogCache().map;
163
+ let map = null;
164
+ try {
165
+ const r = await fetch('http://' + hostHeader + '/api/skin-center/v2/catalog', { signal: AbortSignal.timeout(5000) });
166
+ if (r.ok) {
167
+ const doc = await r.json();
168
+ const skins = (doc && Array.isArray(doc.skins)) ? doc.skins : [];
169
+ map = new Map();
170
+ for (const s of skins) {
171
+ const id = s && s.manifest && s.manifest.id;
172
+ if (typeof id === 'string' && id) map.set(id, Array.isArray(s.warnings) ? s.warnings : []);
173
+ }
174
+ }
175
+ } catch { map = readCatalogCache().map; }
176
+ setCatalogCache(now, map);
177
+ return map;
178
+ }
179
+
142
180
  /** 站点列表缓存(30 秒):面板打开与"刷新"共用,避免每次都打站点。 */
143
181
  const siteCache = { at: 0, items: [], error: null };
144
182
  const SITE_TTL_MS = 30000;
@@ -342,8 +380,19 @@ export function registerSkinsRoutes(webServer, c) {
342
380
  handler: async (req, res) => {
343
381
  const force = /[?&]refresh=1/.test(req.url || '');
344
382
  const site = await fetchSiteSkins(force);
345
- const installed = listInstalled();
383
+ const installedBase = listInstalled();
346
384
  const skinCenter = await probeSkinCenter(req.headers && req.headers.host, force);
385
+ // 关键对账:**它收录了哪几套**。`null` = 问不到(不据此判红)。
386
+ // 被它剔除的皮肤"能装但应用后画面不变"——它客户端会回退到内置默认款,
387
+ // 用户看到的就是"点哪套都还是鲸鱼娘/blue-fantasy"(实测症状)。
388
+ const catalog = skinCenter === 'present'
389
+ ? await fetchSkinCenterCatalog(req.headers && req.headers.host, force)
390
+ : null;
391
+ const installed = installedBase.map((s) => {
392
+ if (!catalog) return { ...s, inCatalog: null, catalogWarnings: [] };
393
+ const hit = catalog.has(s.id) ? s.id : (catalog.has(s.dirId) ? s.dirId : null);
394
+ return { ...s, inCatalog: Boolean(hit), catalogWarnings: hit ? catalog.get(hit) : [] };
395
+ });
347
396
  // ⚠️ 站点 slug 与本机皮肤 id **不总是同一个字符串**:站点上 slug 是全站唯一的,
348
397
  // 撞名时上传工具会加 `skin-` 前缀(实测 blue-throated-bee-eater 同时是桌宠 id)。
349
398
  // 所以比对要归一:两边都去掉可选的 `skin-` 前缀再比,否则会显示成"未安装"。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.13.1",
3
+ "version": "4.14.1",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {