dsh-folia 2.5.7 → 2.5.9

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
@@ -16991,6 +16991,9 @@ window.__ModuleLoader__.load({
16991
16991
  const [state, setState] = React.useState(null);
16992
16992
  const [busy, setBusy] = React.useState("");
16993
16993
  const [note, setNote] = React.useState(null);
16994
+ // 下载没有进度通道(宿主一次性 fetch 完才返回),但等待必须"看得见":
16995
+ // 每秒 +1 的秒表足以让用户知道它没卡死。
16996
+ const [tick, setTick] = React.useState(0);
16994
16997
 
16995
16998
  const load = React.useCallback(async (force) => {
16996
16999
  try {
@@ -17004,9 +17007,16 @@ window.__ModuleLoader__.load({
17004
17007
 
17005
17008
  React.useEffect(() => { load(false); }, [load]);
17006
17009
 
17010
+ React.useEffect(() => {
17011
+ if (!busy) return undefined;
17012
+ const t = setInterval(() => setTick((n) => n + 1), 1000);
17013
+ return () => clearInterval(t);
17014
+ }, [busy]);
17015
+
17007
17016
  async function act(kind, payload, label) {
17008
17017
  if (busy) return;
17009
17018
  setBusy(payload && payload.id ? payload.id : kind);
17019
+ setTick(0);
17010
17020
  setNote(null);
17011
17021
  try {
17012
17022
  const resp = await apiPostJson("/boot/" + kind, payload || {}, { parse: "always" });
@@ -17120,7 +17130,7 @@ window.__ModuleLoader__.load({
17120
17130
  }, label);
17121
17131
  const actions = [];
17122
17132
  if (!c.installed) {
17123
- actions.push(btn(isBusy ? weT("下载中…") : weT("下载"), () => act("download", { id: c.id }, c.title), false, true));
17133
+ actions.push(btn(isBusy ? (weT("下载中… ") + tick + "s") : weT("下载"), () => act("download", { id: c.id }, c.title), false, true));
17124
17134
  } else if (!isActive) {
17125
17135
  actions.push(btn(weT("使用"), () => act("select", { id: c.id }, c.title), false, true));
17126
17136
  actions.push(btn(weT("删除"), () => act("remove", { id: c.id }, c.title), isBusy, false));
@@ -17451,7 +17461,7 @@ window.__ModuleLoader__.load({
17451
17461
  React.createElement("span", { className: "we-qp__item-thumb" },
17452
17462
  w.preview
17453
17463
  ? React.createElement("img", {
17454
- src: w.preview, alt: "", loading: "lazy",
17464
+ src: w.preview, alt: "", loading: "lazy", decoding: "async",
17455
17465
  onError: (e) => { e.target.style.display = "none"; },
17456
17466
  onLoad: (e) => { e.target.style.opacity = "1"; },
17457
17467
  })
@@ -17479,7 +17489,7 @@ window.__ModuleLoader__.load({
17479
17489
  },
17480
17490
  w.preview
17481
17491
  ? React.createElement("img", {
17482
- src: w.preview, alt: "", loading: "lazy",
17492
+ src: w.preview, alt: "", loading: "lazy", decoding: "async",
17483
17493
  onError: (e) => { e.target.style.display = "none"; },
17484
17494
  onLoad: (e) => { e.target.style.opacity = "1"; },
17485
17495
  })
package/lib/index.js CHANGED
@@ -80,6 +80,7 @@ export { extOf };
80
80
  import { registerInventoryRoutes } from './routes/inventory.js';
81
81
  import { registerModulesRoutes } from './routes/modules.js';
82
82
  import { registerBootRoutes } from './routes/boot.js';
83
+ import { registerSiteThumbRoutes } from './routes/site-thumb.js';
83
84
  import { installBootInjection } from './boot/index.js';
84
85
  import { registerDiagRoutes } from './routes/diag.js';
85
86
  // 遮挡消息桥族(上游新增域模块):hub 处理 x11 遮挡探针,路由对外暴露状态/控制面。
@@ -303,6 +304,9 @@ export function apply(ctx) {
303
304
  // 开机动画 → lib/routes/boot.js(HTTP 面)+ lib/boot/index.js(注入与屏幕脚本)。
304
305
  // 注入是「关就一行都不注」:没有选片时不产生任何脚本、不产生任何请求。
305
306
  registerBootRoutes(webServer, { disposers, base: BASE, log });
307
+
308
+ // 站点图片缩略图代理 → lib/routes/site-thumb.js。卡片用 320px 小图,不再每张下 500 KB 原图。
309
+ registerSiteThumbRoutes(webServer, { disposers, base: BASE, ensureImagePreview, log });
306
310
  installBootInjection(ctx, (m) => { try { log(m); } catch { /* ignore */ } });
307
311
 
308
312
  // 3./3a. 派生媒体族路由(源元信息 / 抽帧转码 + 进度 / 视频缩略图)→ lib/routes/media-derived.js。
@@ -126,6 +126,10 @@ const FOLIA_API_CATEGORY = 'wallpaper';
126
126
  /** 明确**不是**壁纸的分类。没列进来的(含未来新增的)一律放行 —— 宁可多显示,不静默丢。 */
127
127
  const FOLIA_NON_WALLPAPER_CATEGORIES = ['boot-animation', 'skin-pack', 'skin', 'pet', 'pets', 'assistant-skin'];
128
128
  /** 分页上限:防止站点游标异常时无限翻页(每页 12~24,20 页足够几百条)。 */
129
+ /** 每次请求要多少条:接口支持 `?limit=`(实测 limit=5 生效),一次要 100 条能把
130
+ * 几百张图的清单压到个位数请求。 */
131
+ const FOLIA_API_LIMIT = 100;
132
+ /** 条目总数上限(防止站点数据异常时无限翻页)。100 × 20 = 2000 条。 */
129
133
  const FOLIA_API_MAX_PAGES = 20;
130
134
  const FOLIA_CATALOG_PATH = '/data/catalog.json';
131
135
  const FOLIA_MANIFEST_PATH = '/manifest.json';
@@ -206,6 +210,13 @@ function foliaItemFromCatalog(it, base) {
206
210
  const media = foliaAbs(base, it && it.url);
207
211
  if (!media) return null;
208
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);
209
220
  const extHit = String(media).match(/\.([a-z0-9]{2,5})(?:[?#]|$)/i);
210
221
  const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined);
211
222
  return {
@@ -217,7 +228,7 @@ function foliaItemFromCatalog(it, base) {
217
228
  media, // 绝对 URL
218
229
  mediaExt: '.' + (extHit ? extHit[1].toLowerCase() : (type === 'video' ? 'mp4' : 'jpg')),
219
230
  // 站点目录可能带 preview/poster;图片没封面时用自己当预览,视频没有就留空(卡片会是黑块)
220
- preview: foliaAbs(base, it && (it.preview || it.poster)) || (type === 'image' ? media : null),
231
+ preview: sitePreview || thumb || null,
221
232
  frameUrl: null,
222
233
  schemeColor: null,
223
234
  sceneLiveSrc: null,
@@ -323,7 +334,7 @@ async function foliaRemote(force = false) {
323
334
  const out = [];
324
335
  let cursor = null;
325
336
  for (let page = 0; page < FOLIA_API_MAX_PAGES; page++) {
326
- const scope = '?category=' + encodeURIComponent(FOLIA_API_CATEGORY);
337
+ const scope = '?category=' + encodeURIComponent(FOLIA_API_CATEGORY) + '&limit=' + FOLIA_API_LIMIT;
327
338
  const qs = cursor ? FOLIA_API_PATH + scope + '&cursor=' + encodeURIComponent(cursor) : FOLIA_API_PATH + scope;
328
339
  const { doc, error } = await fetchJson(qs);
329
340
  if (!doc || !doc.ok || !doc.data) return { items: out, error: error || 'api-shape' };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "2.5.7",
3
+ "version": "2.5.9",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {