dsh-folia 3.9.1 → 4.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.
@@ -84,8 +84,13 @@ function rowStatusOf(probe) {
84
84
  }
85
85
 
86
86
  /* ── Folia 站点数据源 ────────────────────────────────────────────────────────
87
- * 把自建站点(默认 `https://folia-major-3t6.pages.dev`)清单里的条目并进 inventory,
88
- * 于是「本机 Wallpaper Engine 工坊 + 上传库 + 站点」三者在客户端看来是同一个壁纸库。
87
+ * 把自建站点(默认 `https://pub-1275321f146e479eaac54948f4cdad6f.r2.dev`)清单里的条目
88
+ * 并进 inventory,于是「本机 Wallpaper Engine 工坊 + 上传库 + 站点」三者在客户端看来是
89
+ * 同一个壁纸库。
90
+ *
91
+ * 为什么默认值是 folia-5ve.pages.dev:站点后台(D1 + admin)产出 /data/catalog.json,
92
+ * 素材 `<base>/boot/*`)。旧站 `folia-major-3t6.pages.dev` 已废弃 —— 但**换站不需要改代码**,
93
+ * 用 `DSH_FOLIA_SITE` 覆盖即可。
89
94
  *
90
95
  * 契约:
91
96
  * · 地址由 `DSH_FOLIA_SITE` 覆盖;`=off` 关闭整条通道(测试/离线场景用)。
@@ -95,9 +100,39 @@ function rowStatusOf(probe) {
95
100
  * · 产出条目**自带绝对 URL**(`media` / `preview`),客户端 `apiUrl()` 对绝对 URL 原样放行。
96
101
  *
97
102
  * 站点格式(`<site>/manifest.json`):`{ images: [{ file, title, url, preview, kind?, animated? }] }`
98
- * —— 与 `folia-wallpaper-site` 产物一致。
103
+ * —— 与 `folia-wallpaper-site` 产物一致;`url`/`preview` 可以是相对路径(相对站点基址)。
104
+ */
105
+ const FOLIA_SITE = 'https://folia-5ve.pages.dev';
106
+ /**
107
+ * 站点数据源的两个可能形态,按顺序尝试:
108
+ * · `/data/catalog.json` —— 站点(D1 + admin)产出的目录,**当前的真源**
109
+ * · `/manifest.json` —— 早期静态清单,保留兼容(站点上没有 catalog 时回退)
110
+ * 顺序有讲究:catalog 是站点后台维护的,manifest 是我们手写的;两者都在时以站点为准。
111
+ */
112
+ /**
113
+ * 站点的**实时接口**。它比静态快照优先:快照只在部署时生成(我们实测过它停在某个时间点,
114
+ * 而站点后台早就加了新图),接口才是"你传完就能看到"的那条路。
115
+ *
116
+ * 形状:`{ ok, data: { items: [...], total, hasMore, nextCursor } }`,按游标分页。
99
117
  */
100
- const FOLIA_SITE = 'https://folia-major-3t6.pages.dev';
118
+ const FOLIA_API_PATH = '/api/public/wallpapers';
119
+ /**
120
+ * 只要壁纸分类。站点分类不止壁纸(还有 `boot-animation` / `skin-pack` / 桌宠),
121
+ * 而它们的 `kind` 同样是 `dynamic`/`static` —— 只按类型判会把开机动画当成壁纸拉进来。
122
+ * 服务端支持 `?category=`,优先用它(少传无用数据);客户端的 `foliaIsWallpaperCategory`
123
+ * 是兜底,防服务端忽略参数或以后新增分类。
124
+ */
125
+ const FOLIA_API_CATEGORY = 'wallpaper';
126
+ /** 明确**不是**壁纸的分类。没列进来的(含未来新增的)一律放行 —— 宁可多显示,不静默丢。 */
127
+ const FOLIA_NON_WALLPAPER_CATEGORIES = ['boot-animation', 'skin-pack', 'skin', 'pet', 'pets', 'assistant-skin'];
128
+ /** 分页上限:防止站点游标异常时无限翻页(每页 12~24,20 页足够几百条)。 */
129
+ /** 每次请求要多少条:接口支持 `?limit=`(实测 limit=5 生效),一次要 100 条能把
130
+ * 几百张图的清单压到个位数请求。 */
131
+ const FOLIA_API_LIMIT = 100;
132
+ /** 条目总数上限(防止站点数据异常时无限翻页)。100 × 20 = 2000 条。 */
133
+ const FOLIA_API_MAX_PAGES = 20;
134
+ const FOLIA_CATALOG_PATH = '/data/catalog.json';
135
+ const FOLIA_MANIFEST_PATH = '/manifest.json';
101
136
  const FOLIA_TTL_MS = 60000;
102
137
  const FOLIA_TIMEOUT_DEFAULT_MS = 4000;
103
138
  const FOLIA_TIMEOUT_MIN_MS = 1000;
@@ -133,6 +168,110 @@ function foliaAbs(base, u) {
133
168
  return base + (s.startsWith('/') ? s : '/' + s);
134
169
  }
135
170
 
171
+ /**
172
+ * 条目类型。优先级:**mime** > kind > URL 后缀。
173
+ *
174
+ * 站点目录的条目长这样:`{ slug, title, kind: 'dynamic', mime: 'video/mp4', url: '/media/...' }` ——
175
+ * 它没有 `file` 字段,而且 `kind: 'dynamic'` 既可能是视频也可能是有动画的图,所以 mime 最可靠。
176
+ */
177
+ function foliaTypeOfEntry(it) {
178
+ const mime = String((it && it.mime) || '').toLowerCase();
179
+ if (mime.startsWith('video/')) return 'video';
180
+ if (mime.startsWith('image/')) return 'image';
181
+ const kind = String((it && it.kind) || '').toLowerCase();
182
+ if (kind === 'video' || kind === 'dynamic') return 'video';
183
+ if (kind === 'image') return 'image';
184
+ const probe = String((it && (it.file || it.url || it.slug)) || '');
185
+ if (/\.(mp4|webm|mov|m4v)$/i.test(probe)) return 'video';
186
+ if (/\.(jpg|jpeg|png|webp|gif|avif)$/i.test(probe)) return 'image';
187
+ return null;
188
+ }
189
+
190
+ /** 条目的分类 slug(没有就返回空串)。 */
191
+ function foliaCategoryOf(it) {
192
+ const c = it && it.category;
193
+ return String((c && c.slug) || '').trim().toLowerCase();
194
+ }
195
+
196
+ /**
197
+ * 它是不是一条"壁纸"。黑名单制:只有明确属于其它模块的分类才排除。
198
+ * 不用白名单,是因为站点以后加分类时白名单会静默把新东西吞掉。
199
+ */
200
+ function foliaIsWallpaperCategory(it) {
201
+ const slug = foliaCategoryOf(it);
202
+ if (!slug) return true;
203
+ return !FOLIA_NON_WALLPAPER_CATEGORIES.includes(slug);
204
+ }
205
+
206
+ /** 站点目录的一条 → inventory 条目。判不出类型/没有媒体地址就返回 null。 */
207
+ function foliaItemFromCatalog(it, base) {
208
+ const type = foliaTypeOfEntry(it);
209
+ if (!type) return null;
210
+ const media = foliaAbs(base, it && it.url);
211
+ if (!media) return null;
212
+ const slug = String((it && (it.slug || it.id)) || '');
213
+ // 站点图片的预览走宿主缩略图代理:卡片只下 ~10 KB,不再下 500 KB 原图。
214
+ // (视频没有封面,保持 null —— 卡片会显示「无预览」,不会去拉整段视频。)
215
+ // 预览优先级:**站点自己给的 preview 最高**(省掉宿主这一跳,也最省流量);
216
+ // 站点没给才退回宿主缩略图代理。只有预览走小图,`media` 永远是原图 ——
217
+ // 应用壁纸用的是 `media`,所以画质不受影响。
218
+ const sitePreview = foliaAbs(base, it && (it.preview || it.poster)) || null;
219
+ const thumb = sitePreview ? null : (type === 'image' ? '/folia/site-thumb?u=' + encodeURIComponent(media) : null);
220
+ const extHit = String(media).match(/\.([a-z0-9]{2,5})(?:[?#]|$)/i);
221
+ const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined);
222
+ return {
223
+ id: 'folia-' + (slug || media),
224
+ title: String((it && it.title) || slug || 'Folia'),
225
+ type, // 'video' | 'image'
226
+ contentrating: null,
227
+ playable: true,
228
+ media, // 绝对 URL
229
+ mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
230
+ // 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
231
+ preview: sitePreview || thumb || null,
232
+ frameUrl: null,
233
+ schemeColor: null,
234
+ sceneLiveSrc: null,
235
+ sceneVideo: null,
236
+ sceneAudio: null,
237
+ propsUrl: null,
238
+ liveFrame: null,
239
+ source: 'folia',
240
+ bytes: num(it && it.bytes),
241
+ width: num(it && it.width),
242
+ height: num(it && it.height),
243
+ durationMs: num(it && it.durationMs),
244
+ };
245
+ }
246
+
247
+ /** 旧静态清单 `{ images: [...] }` 的一条 → inventory 条目。 */
248
+ function foliaItemFromManifest(it, base) {
249
+ const type = foliaTypeOfEntry(it);
250
+ if (!type) return null;
251
+ const file = String((it && (it.file || it.id)) || '');
252
+ const media = foliaAbs(base, it && it.url);
253
+ if (!media) return null;
254
+ const ext = (file.split('.').pop() || (type === 'video' ? 'mp4' : 'jpg')).toLowerCase();
255
+ return {
256
+ id: 'folia-' + (file || media),
257
+ title: String((it && it.title) || file || 'Folia'),
258
+ type,
259
+ contentrating: null,
260
+ playable: true,
261
+ media,
262
+ mediaExt: '.' + ext,
263
+ preview: foliaAbs(base, it && it.preview) || (type === 'image' ? media : null),
264
+ frameUrl: null,
265
+ schemeColor: null,
266
+ sceneLiveSrc: null,
267
+ sceneVideo: null,
268
+ sceneAudio: null,
269
+ propsUrl: null,
270
+ liveFrame: null,
271
+ source: 'folia',
272
+ };
273
+ }
274
+
136
275
  /** 条目类型:先看 `kind`,回落到文件后缀。判不出返回 null(丢弃)。 */
137
276
  function foliaTypeOf(it) {
138
277
  const file = String(it && it.file ? it.file : '');
@@ -160,65 +299,92 @@ async function foliaRemote(force = false) {
160
299
  }
161
300
 
162
301
  const startedAt = Date.now();
163
- const url = base + '/manifest.json';
164
302
  /** @type {any[]} */
165
303
  let items = [];
166
304
  let error = null;
305
+ let usedPath = FOLIA_CATALOG_PATH;
167
306
 
168
- try {
307
+ /** 抓一个 JSON;返回 { doc, error },永不抛。 */
308
+ const fetchJson = async (path) => {
169
309
  const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
170
310
  ? AbortSignal.timeout(foliaTimeoutMs())
171
311
  : undefined;
172
- const res = await fetch(url, { headers: { accept: 'application/json' }, signal });
173
- if (!res.ok) {
174
- error = 'http-' + res.status;
175
- } else {
176
- let doc;
312
+ try {
313
+ const res = await fetch(base + path, { headers: { accept: 'application/json' }, signal });
314
+ if (!res.ok) return { doc: null, error: 'http-' + res.status };
177
315
  try {
178
- doc = await res.json();
316
+ return { doc: await res.json(), error: null };
179
317
  } catch {
180
- error = 'json';
181
- }
182
- if (doc) {
183
- const list = Array.isArray(doc.images) ? doc.images : [];
184
- for (const it of list) {
185
- const type = foliaTypeOf(it);
186
- if (!type) continue;
187
- const file = String((it && (it.file || it.id)) || '');
188
- const media = foliaAbs(base, it && it.url);
189
- if (!media) continue;
190
- const ext = (file.split('.').pop() || (type === 'video' ? 'mp4' : 'jpg')).toLowerCase();
191
- items.push({
192
- id: 'folia-' + (file || media),
193
- title: String((it && it.title) || file || 'Folia'),
194
- type, // 'video' | 'image'
195
- contentrating: null,
196
- playable: true,
197
- media, // 绝对 URL
198
- mediaExt: '.' + ext,
199
- preview: foliaAbs(base, it && it.preview) || (type === 'image' ? media : null),
200
- frameUrl: null,
201
- schemeColor: null,
202
- sceneLiveSrc: null,
203
- sceneVideo: null,
204
- sceneAudio: null,
205
- propsUrl: null,
206
- liveFrame: null,
207
- source: 'folia', // 客户端可据此区分来源(未知字段会被忽略)
208
- });
209
- }
318
+ return { doc: null, error: 'json' };
210
319
  }
320
+ } catch (e) {
321
+ const code = String((e && (e.name || e.code)) || '');
322
+ const msg = String((e && e.message) || '');
323
+ if (/timeout|abort/i.test(code) || /timeout|aborted/i.test(msg)) return { doc: null, error: 'timeout' };
324
+ if (/cert|tls|ssl/i.test(msg)) return { doc: null, error: 'tls' };
325
+ return { doc: null, error: 'network' };
326
+ }
327
+ };
328
+
329
+ /**
330
+ * 拉接口的全部条目(按游标翻页)。任何一页失败就停,用已拿到的那些 ——
331
+ * 半份列表也比空列表有用。
332
+ */
333
+ const fetchApiItems = async () => {
334
+ const out = [];
335
+ let cursor = null;
336
+ for (let page = 0; page < FOLIA_API_MAX_PAGES; page++) {
337
+ const scope = '?category=' + encodeURIComponent(FOLIA_API_CATEGORY) + '&limit=' + FOLIA_API_LIMIT;
338
+ const qs = cursor ? FOLIA_API_PATH + scope + '&cursor=' + encodeURIComponent(cursor) : FOLIA_API_PATH + scope;
339
+ const { doc, error } = await fetchJson(qs);
340
+ if (!doc || !doc.ok || !doc.data) return { items: out, error: error || 'api-shape' };
341
+ const items = Array.isArray(doc.data.items) ? doc.data.items : [];
342
+ out.push(...items);
343
+ if (!doc.data.hasMore || !doc.data.nextCursor) return { items: out, error: null };
344
+ cursor = doc.data.nextCursor;
211
345
  }
212
- } catch (e) {
213
- const code = String((e && (e.name || e.code)) || '');
214
- const msg = String((e && e.message) || '');
215
- if (/timeout|abort/i.test(code) || /timeout|aborted/i.test(msg)) error = 'timeout';
216
- else if (/cert|tls|ssl/i.test(msg)) error = 'tls';
217
- else error = 'network';
346
+ return { items: out, error: null };
347
+ };
348
+
349
+ // ① 站点**接口**(真源):`{ ok, data: { items: [...] } }`
350
+ const api = await fetchApiItems();
351
+ if (api.items.length) {
352
+ for (const it of api.items) {
353
+ if (!foliaIsWallpaperCategory(it)) continue; // 兜底:服务端过滤失效时也不把开机动画当壁纸
354
+ const one = foliaItemFromCatalog(it, base);
355
+ if (one) items.push(one);
356
+ }
357
+ usedPath = FOLIA_API_PATH + '?category=' + FOLIA_API_CATEGORY;
218
358
  }
219
359
 
360
+ // ② 站点目录(静态快照):`{ wallpapers: [{ slug, mime, url, ... }] }`
361
+ if (items.length === 0) {
362
+ const cat = await fetchJson(FOLIA_CATALOG_PATH);
363
+ if (cat.doc && Array.isArray(cat.doc.wallpapers)) {
364
+ for (const it of cat.doc.wallpapers) {
365
+ if (!foliaIsWallpaperCategory(it)) continue; // 快照里也混着开机动画,同样按分类排掉
366
+ const one = foliaItemFromCatalog(it, base);
367
+ if (one) items.push(one);
368
+ }
369
+ if (items.length === 0) error = 'empty';
370
+ } else {
371
+ // ② 回退到旧静态清单:`{ images: [{ file, url, ... }] }`
372
+ usedPath = FOLIA_MANIFEST_PATH;
373
+ const man = await fetchJson(FOLIA_MANIFEST_PATH);
374
+ if (man.doc && Array.isArray(man.doc.images)) {
375
+ for (const it of man.doc.images) {
376
+ const one = foliaItemFromManifest(it, base);
377
+ if (one) items.push(one);
378
+ }
379
+ if (items.length === 0) error = 'empty';
380
+ } else {
381
+ // 两条都不成立:报 catalog 的错(它是真源,错因更有诊断价值)
382
+ error = cat.error || man.error || 'network';
383
+ }
384
+ }
385
+ }
220
386
  const status = {
221
- url,
387
+ url: base + usedPath,
222
388
  ok: error === null,
223
389
  count: items.length,
224
390
  ms: Date.now() - startedAt,
@@ -0,0 +1,186 @@
1
+ /**
2
+ * routes/modules.js — 数据模块索引(站点侧声明"有哪些数据模块")。
3
+ *
4
+ * 为什么需要它:插件的能力是固定的(壁纸库、开机动画…),但**站点会一直加新东西**。
5
+ * 把"有哪些模块、每个模块的清单在哪、该用哪个 handler 处理"放进一份索引里,插件就不必
6
+ * 为每加一批素材发一次版 —— 它读索引,按 handler 决定自己认不认识。
7
+ *
8
+ * 解析顺序(**站点优先,R2 兜底,内置最后**):
9
+ *
10
+ * ① `<site>/data/modules.json` 站点自己维护的索引(你以后放进 Pages 即优先)
11
+ * ② `<assets>/data/modules.json` R2 桶上的索引(当前由我们维护)
12
+ * ③ 内置默认 两条都读不到时的最小集合
13
+ *
14
+ * 页面/助手只调 `/folia/modules` 这一个入口,具体从哪来的写在 `source` 里 —— 排查时
15
+ * 不用猜"到底读的哪份"。
16
+ */
17
+ import { sendJson } from '../json-response.js';
18
+
19
+ /** 站点基址(与 inventory 同一套环境变量,保持"换站只改一处"的手感)。 */
20
+ const DEFAULT_SITE = 'https://folia-5ve.pages.dev';
21
+ /** 素材桶基址:托管 <site> 上暂时没有的模块清单(例如 boot 素材)。 */
22
+ const DEFAULT_ASSETS = 'https://pub-1275321f146e479eaac54948f4cdad6f.r2.dev';
23
+
24
+ /**
25
+ * 索引可能落在哪。**按顺序试**,第一个返回 JSON 的胜出:
26
+ *
27
+ * /data/modules.json 站点自己的静态路径(你以后把文件放进 Pages 时用这条)
28
+ * /media/data/modules.json 站点的 /media 代理 —— 它把 /media/X 映射到 R2 的键 X,
29
+ * 所以这条能读到 R2 上的 data/modules.json(现在走这条)
30
+ *
31
+ * 为什么重要:站点的 /data/* 没有 Function,未知路径会回退 index.html;
32
+ * 而 /media/* 有代理。走 /media/ 让"全部数据都从站点域名来"成立,R2 只是背后的仓库。
33
+ */
34
+ const MODULES_PATHS = ['/data/modules.json', '/media/data/modules.json'];
35
+ const TTL_MS = 60000;
36
+ const TIMEOUT_MS = 8000;
37
+
38
+ function siteBase() {
39
+ const raw = process.env.DSH_FOLIA_SITE;
40
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
41
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_SITE;
42
+ return base.replace(/\/+$/, '');
43
+ }
44
+
45
+ function assetsBase() {
46
+ const raw = process.env.DSH_FOLIA_ASSETS;
47
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_ASSETS;
48
+ return base.replace(/\/+$/, '');
49
+ }
50
+
51
+ /**
52
+ * 内置默认索引。**只在两条网络源都不可用时使用** —— 它同时也是一份"契约示例":
53
+ * 想加模块时照这个形状写,不必读代码。
54
+ */
55
+ const BUILTIN = {
56
+ version: 1,
57
+ modules: [
58
+ {
59
+ id: 'wallpapers',
60
+ name: '壁纸',
61
+ handler: 'wallpaper-library',
62
+ status: 'live',
63
+ manifest: '/data/catalog.json',
64
+ fallbackManifest: '/manifest.json',
65
+ },
66
+ {
67
+ id: 'boot',
68
+ name: '开机动画',
69
+ handler: 'boot-animation',
70
+ status: 'planned',
71
+ manifest: '/boot/manifest.json',
72
+ },
73
+ ],
74
+ };
75
+
76
+ /** @type {{ at: number, payload: any | null }} */
77
+ let cache = { at: 0, payload: null };
78
+
79
+ /** 形状校验:宁可判为无效(回落下一层),也不把半截数据当索引下发。 */
80
+ function normalize(doc, origin) {
81
+ if (!doc || typeof doc !== 'object') return null;
82
+ const list = Array.isArray(doc.modules) ? doc.modules : null;
83
+ if (!list) return null;
84
+ const modules = [];
85
+ for (const m of list) {
86
+ if (!m || typeof m !== 'object') continue;
87
+ const id = String(m.id || '').trim();
88
+ const handler = String(m.handler || '').trim();
89
+ if (!id || !handler) continue; // 没有 id 或 handler 的条目无法被使用
90
+ modules.push({
91
+ id,
92
+ name: String(m.name || id),
93
+ handler,
94
+ status: String(m.status || 'live'),
95
+ manifest: m.manifest ? String(m.manifest) : null,
96
+ fallbackManifest: m.fallbackManifest ? String(m.fallbackManifest) : null,
97
+ description: m.description ? String(m.description) : null,
98
+ requirements: m.requirements && typeof m.requirements === 'object' ? m.requirements : null,
99
+ });
100
+ }
101
+ return { version: doc.version || 1, updatedAt: doc.updatedAt || null, origin, modules };
102
+ }
103
+
104
+ async function fetchJson(url) {
105
+ // 网络抖动(境外 CDN 偶发 connect timeout)不该让索引退回内置集合 —— 重试一次。
106
+ // 只对 network/timeout 重试;`not-json` / `http-4xx` 是确定性结果,重试没意义。
107
+ let lastError = 'network';
108
+ for (let attempt = 0; attempt < 2; attempt++) {
109
+ try {
110
+ const res = await fetch(url, {
111
+ headers: { accept: 'application/json' },
112
+ signal: AbortSignal.timeout(TIMEOUT_MS),
113
+ });
114
+ if (!res.ok) return { doc: null, error: 'http-' + res.status };
115
+ const ct = String(res.headers.get('content-type') || '');
116
+ // 站点把未知路径回落到 index.html,这里必须挡住:HTML 不是索引。
117
+ if (ct && !/json/i.test(ct)) return { doc: null, error: 'not-json' };
118
+ return { doc: await res.json(), error: null };
119
+ } catch (e) {
120
+ const msg = String((e && e.message) || '');
121
+ lastError = /timeout|abort/i.test(msg) ? 'timeout' : 'network';
122
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 600));
123
+ }
124
+ }
125
+ return { doc: null, error: lastError };
126
+ }
127
+
128
+ /**
129
+ * 解析模块索引。永不抛 —— 索引只是"能多发现一点东西",读不到就必须退回内置集合,
130
+ * 绝不能让面板因为一个索引文件而空白。
131
+ */
132
+ export async function resolveModules(force = false) {
133
+ if (!force && Date.now() - cache.at < TTL_MS && cache.payload) return cache.payload;
134
+
135
+ const tried = [];
136
+ const site = siteBase();
137
+ const assets = assetsBase();
138
+
139
+ // 站点先试(它优先),每个基址再按路径表试;R2 直接地址留作最后兜底。
140
+ const bases = [
141
+ ...(site ? [{ base: site, origin: 'site' }] : []),
142
+ { base: assets, origin: 'assets' },
143
+ ];
144
+ for (const { base, origin } of bases) {
145
+ for (const path of MODULES_PATHS) {
146
+ const url = base + path;
147
+ const { doc, error } = await fetchJson(url);
148
+ tried.push({ url, error });
149
+ if (doc) {
150
+ const norm = normalize(doc, origin);
151
+ if (norm) {
152
+ // 相对清单路径补成绝对 URL:调用方不必再关心基址是谁。
153
+ for (const m of norm.modules) {
154
+ if (m.manifest && m.manifest.startsWith('/')) m.manifest = base + m.manifest;
155
+ if (m.fallbackManifest && m.fallbackManifest.startsWith('/')) m.fallbackManifest = base + m.fallbackManifest;
156
+ }
157
+ const payload = { ok: true, source: norm.origin, url, updatedAt: norm.updatedAt, modules: norm.modules, tried };
158
+ cache = { at: Date.now(), payload };
159
+ return payload;
160
+ }
161
+ tried[tried.length - 1].error = 'bad-shape';
162
+ }
163
+ }
164
+ }
165
+
166
+ const payload = { ok: false, source: 'builtin', url: null, updatedAt: null, modules: BUILTIN.modules, tried };
167
+ cache = { at: Date.now(), payload };
168
+ return payload;
169
+ }
170
+
171
+ export function registerModulesRoutes(webServer, c) {
172
+ const { disposers, base: BASE } = c;
173
+
174
+ disposers.push(webServer.register({
175
+ kind: 'exact',
176
+ path: `${BASE}/modules`,
177
+ handler: async (req, res) => {
178
+ let force = false;
179
+ try {
180
+ force = new URL(req.url || '/', 'http://x').searchParams.get('refresh') === '1';
181
+ } catch { /* 解析失败就当没传 */ }
182
+ const payload = await resolveModules(force);
183
+ sendJson(res, 200, payload);
184
+ },
185
+ }));
186
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * routes/site-thumb.js — 站点图片的**缩略图代理**。
3
+ *
4
+ * 为什么需要它:站点上的壁纸是 1024×1024 起的原图(实测一张 536 KB)。卡片网格一页
5
+ * 9~12 张,一次要下 5 MB 左右,在慢网络上就是"站点壁纸加载很慢"。
6
+ *
7
+ * 做法:宿主把原图抓一次存到缓存,然后用**已有的** `ensureImagePreview`(ffmpeg 缩到
8
+ * 320px、q=5,约 10 KB)生成小图,之后每次都直接发小图。原图只下一次、只在第一次。
9
+ *
10
+ * 三条纪律:
11
+ * · **只代理白名单主机**(站点基址与素材桶),避免变成任意 URL 的开放代理;
12
+ * · 任何失败都 **302 回原图** —— 慢一点但一定能显示,绝不 404;
13
+ * · 缩略图与源图都按 URL 的 sha1 命名,天然幂等,不需要失效逻辑。
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { createReadStream, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { extname, join } from 'node:path';
19
+
20
+ const MAX_SOURCE_BYTES = 32 * 1024 * 1024;
21
+ const FETCH_TIMEOUT_MS = 30000;
22
+
23
+ const DEFAULT_SITE = 'https://folia-5ve.pages.dev';
24
+ const DEFAULT_ASSETS = 'https://pub-1275321f146e479eaac54948f4cdad6f.r2.dev';
25
+
26
+ function siteBase() {
27
+ const raw = process.env.DSH_FOLIA_SITE;
28
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
29
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_SITE;
30
+ return base.replace(/\/+$/, '');
31
+ }
32
+ function assetsBase() {
33
+ const raw = process.env.DSH_FOLIA_ASSETS;
34
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : DEFAULT_ASSETS;
35
+ return base.replace(/\/+$/, '');
36
+ }
37
+
38
+ /** 源图缓存目录(与 config.json 同一个数据根)。 */
39
+ function sourceDir() {
40
+ const base = process.env.DSH_FOLIA_HOME || join(homedir(), '.dsh-folia');
41
+ const d = join(base, 'site-src');
42
+ try { if (!existsSync(d)) mkdirSync(d, { recursive: true }); } catch { /* 只读盘:后面会失败并回退 */ }
43
+ return d;
44
+ }
45
+
46
+ /** 只允许抓我们自己的两个主机。 */
47
+ function allowed(url) {
48
+ try {
49
+ const u = new URL(url);
50
+ if (!/^https?:$/.test(u.protocol)) return false;
51
+ const hosts = [siteBase(), assetsBase()].filter(Boolean).map((b) => { try { return new URL(b).host; } catch { return ''; } });
52
+ return hosts.includes(u.host);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export function registerSiteThumbRoutes(webServer, c) {
59
+ const { disposers, base: BASE, ensureImagePreview, log } = c;
60
+
61
+ disposers.push(webServer.register({
62
+ kind: 'exact',
63
+ path: `${BASE}/site-thumb`,
64
+ handler: async (req, res) => {
65
+ let target = '';
66
+ try { target = new URL(req.url || '/', 'http://x').searchParams.get('u') || ''; } catch { target = ''; }
67
+
68
+ const redirect = () => {
69
+ res.statusCode = 302;
70
+ if (typeof res.setHeader === 'function') res.setHeader('location', target);
71
+ res.end();
72
+ };
73
+
74
+ if (!target || !allowed(target)) { redirect(); return; }
75
+
76
+ const key = createHash('sha1').update(target).digest('hex');
77
+ let ext = '.jpg';
78
+ try { ext = extname(new URL(target).pathname) || '.jpg'; } catch { /* 用默认 */ }
79
+ const src = join(sourceDir(), key + (ext.length <= 5 ? ext : '.jpg'));
80
+
81
+ try {
82
+ if (!existsSync(src) || statSync(src).size === 0) {
83
+ const r = await fetch(target, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
84
+ if (!r.ok) { redirect(); return; }
85
+ const ct = String(r.headers.get('content-type') || '');
86
+ if (!/^image\//i.test(ct)) { redirect(); return; }
87
+ const buf = Buffer.from(await r.arrayBuffer());
88
+ if (buf.length === 0 || buf.length > MAX_SOURCE_BYTES) { redirect(); return; }
89
+ writeFileSync(src, buf);
90
+ }
91
+ // 已有套件:缩到 320px(失败返回 null ⇒ 回落原图)
92
+ const thumb = await ensureImagePreview(src).catch(() => null);
93
+ const file = thumb && existsSync(thumb) ? thumb : src;
94
+ const size = statSync(file).size;
95
+ if (typeof res.setHeader === 'function') {
96
+ res.setHeader('content-type', 'image/jpeg');
97
+ res.setHeader('content-length', String(size));
98
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
99
+ }
100
+ res.statusCode = 200;
101
+ createReadStream(file).pipe(res);
102
+ } catch (e) {
103
+ if (typeof log === 'function') log(`[site-thumb] ${String((e && e.message) || e)}`);
104
+ try { if (existsSync(src)) rmSync(src); } catch { /* ignore */ }
105
+ redirect();
106
+ }
107
+ },
108
+ }));
109
+ }
@@ -596,6 +596,8 @@ const DEFAULTS = {
596
596
  contentRatingFilter: "everyone",
597
597
  // Wallpaper-type filter (all / video / web / image / scene). "all" disables it.
598
598
  typeFilter: "all",
599
+ // 壁纸来源:all / local(本机 Wallpaper Engine 库 + 上传)/ site(我们站点)
600
+ sourceFilter: "all",
599
601
  // Thumbnail-card style: "classic" (WE's original aspect-ratio 16/9 cards —
600
602
  // the CD-like look the author liked; can overlap in older browsers) or
601
603
  // "fixed" (fixed-height cards that never overlap). The vinyl
@@ -1040,6 +1042,7 @@ const KINDS = {
1040
1042
  objectFit: { kind: 'enum', values: OBJECT_FIT_VALUES },
1041
1043
  contentRatingFilter: { kind: 'enum', values: RATING_VALUES },
1042
1044
  typeFilter: { kind: 'enum', values: TYPE_VALUES },
1045
+ sourceFilter: { kind: 'enum', values: ['all', 'local', 'site'] },
1043
1046
  pickerLayout: { kind: 'enum', values: PICKER_LAYOUT_VALUES },
1044
1047
  edgeCompat: { kind: 'boolTrue' },
1045
1048
  accent: { kind: 'hex' },
@@ -1291,7 +1294,7 @@ const RESET_TABS = {
1291
1294
  library: [
1292
1295
  'switchTransition', 'switchTransitionDir', 'switchTransitionSpeed',
1293
1296
  'rotationEnabled', 'rotationGroupId', 'rotationGroups', 'rotationSeeded',
1294
- 'contentRatingFilter', 'typeFilter', 'pickerLayout',
1297
+ 'contentRatingFilter', 'typeFilter', 'sourceFilter', 'pickerLayout',
1295
1298
  ],
1296
1299
  // 外观:玻璃 / 侧栏与四角 / 标题栏 / 思考块 / 浮层 / 主题预设 / 字体 / UI 配色。
1297
1300
  appearance: [
@@ -1926,7 +1929,7 @@ const SETTINGS_NAMESPACES = {
1926
1929
  'id', 'hiddenIds', 'rotationEnabled', 'rotationGroupId', 'rotationGroups', 'rotationSeeded',
1927
1930
  'playbackRate', 'videoVolume', 'videoAudioEnabled', 'fpsCap', 'sceneLive', 'sceneLiveFps',
1928
1931
  'liveBootDelay', 'sceneLiveFailures', 'pauseOnHidden', 'pauseOnBlur', 'pauseOnBattery',
1929
- 'flip', 'objectFit', 'contentRatingFilter', 'typeFilter', 'pickerLayout', 'edgeCompat',
1932
+ 'flip', 'objectFit', 'contentRatingFilter', 'typeFilter', 'sourceFilter', 'pickerLayout', 'edgeCompat',
1930
1933
  'userProps', 'noticeSeen', 'layerPositionX', 'layerPositionY', 'layerScale',
1931
1934
  ],
1932
1935
  // 玻璃 / 材质 / 侧栏 / 过渡(`accent`、`glassMode` 与皮肤让路之外的整套玻璃登记表都在这里)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "3.9.1",
3
+ "version": "4.0.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,6 +50,7 @@
50
50
  "lib/kernel/config.js",
51
51
  "lib/kernel/host-kernel.js",
52
52
  "lib/kernel/host-session.js",
53
+ "lib/boot/",
53
54
  "lib/routes/",
54
55
  "lib/occlusion/",
55
56
  "lib/fontsets/",
@@ -188,7 +189,9 @@
188
189
  "verify:static": "node test/verify-static.mjs",
189
190
  "smoke": "node test/run-guards.mjs smoke",
190
191
  "verify:all": "node test/run-guards.mjs all",
191
- "prepack": "node scripts/prepare.mjs"
192
+ "prepack": "node scripts/prepare.mjs",
193
+ "test:boot": "node test/boot-http.mjs",
194
+ "gate": "node scripts/build-client.mjs \u0026\u0026 node test/run-guards.mjs smoke \u0026\u0026 node test/boot-http.mjs"
192
195
  },
193
196
  "peerDependencies": {
194
197
  "@deepseek-ai/cordis": "^4.0.4",
@@ -209,7 +212,6 @@
209
212
  }
210
213
  },
211
214
  "dependencies": {
212
- "@linxin666/dsh-client-ui-skin-center": "^0.4.8",
213
215
  "webwallgl": "^3.0.0",
214
216
  "x11": "^4.3.1"
215
217
  },