dsh-folia 4.18.0 → 4.19.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
@@ -19409,6 +19409,49 @@ window.__ModuleLoader__.load({
19409
19409
  }
19410
19410
  }
19411
19411
 
19412
+ /**
19413
+ * 缓存与速度自述(④)。
19414
+ *
19415
+ * 目的:用户问"为什么慢"时,答案应该在界面上,而不是让他开 F12 或来问作者。
19416
+ * 读宿主 `/folia/site-media-stats`(4.15.3 起存在):命中/未命中/平均吞吐/缓存是否可写。
19417
+ * 三条纪律:拿不到就**什么都不显示**;每 15 秒最多取一次;任何异常都吞掉(不许影响面板)。
19418
+ */
19419
+ function QpCacheHint() {
19420
+ const [info, setInfo] = React.useState(null);
19421
+ React.useEffect(() => {
19422
+ let alive = true;
19423
+ let timer = null;
19424
+ const pull = () => {
19425
+ (async () => {
19426
+ try {
19427
+ const r = typeof apiJson === "function" ? await apiJson("/site-media-stats") : null;
19428
+ const d = (r && r.data) || r || null;
19429
+ if (alive && d && typeof d === "object") setInfo(d);
19430
+ } catch { /* 端点不存在(老宿主)⇒ 保持不显示 */ }
19431
+ })();
19432
+ };
19433
+ pull();
19434
+ try { timer = window.setInterval(pull, 15000); } catch { timer = null; }
19435
+ return () => {
19436
+ alive = false;
19437
+ if (timer !== null) { try { window.clearInterval(timer); } catch { /* ignore */ } }
19438
+ };
19439
+ }, []);
19440
+ if (!info) return null;
19441
+ const hits = Number(info.hits) || 0;
19442
+ const misses = Number(info.misses) || 0;
19443
+ const mbps = Number(info.avgMBps) || 0;
19444
+ const writable = info.writable !== false;
19445
+ const parts = [
19446
+ weT("缓存命中 {h} · 首次下载 {m}", { h: hits, m: misses }),
19447
+ mbps > 0 ? weT("平均 {v} MB/s", { v: mbps.toFixed(1) }) : "",
19448
+ writable ? "" : weT("⚠ 缓存目录不可写(每次都会重下)"),
19449
+ ].filter(Boolean);
19450
+ return React.createElement("div", {
19451
+ className: "we-picker__hint",
19452
+ style: { marginTop: "2px", opacity: 0.75, fontSize: "11px" },
19453
+ }, parts.join(" · "));
19454
+ }
19412
19455
  function qpRenderCard(w, sel) {
19413
19456
  // ── 卡:缩略图网格形态(视图切换的「卡片」档;点击语义与列表行一致)──
19414
19457
  return React.createElement("div", {
@@ -19479,6 +19522,9 @@ window.__ModuleLoader__.load({
19479
19522
  ? qpTypeLabel(current, sel) + weT(playbackLive ? " · 播放中" : " · 已暂停")
19480
19523
  : weT("从下面列表挑一张")),
19481
19524
  sel.videoError && React.createElement("div", { className: "we-picker__current-error" }, sel.videoError),
19525
+ // 缓存与速度的**自述**(④):用户不用开 F12 也能知道"这次是首次下载还是命中缓存"。
19526
+ // 数据来自宿主 `/folia/site-media-stats`;拿不到就**什么都不显示**(绝不因此报错)。
19527
+ React.createElement(QpCacheHint, null),
19482
19528
  ),
19483
19529
  React.createElement("div", { className: "we-qp__current-actions" },
19484
19530
  // ⚠️ **不变量:这一行只放日常操作**(暂停 / 播放 · 刷新 · 清除;场景与网页壁纸再加
@@ -114,6 +114,62 @@ export function createDownloadQueue(opts = {}) {
114
114
  return true;
115
115
  }
116
116
 
117
+ /**
118
+ * **分片并发下载**(针对高延迟线路)。
119
+ *
120
+ * 为什么需要:单条 TCP 连接在高 RTT 线路上会被窗口/慢启动限制,实测几百 KB/s;
121
+ * 同一台机器改成 3 条 Range 并发通常能到 1.5–3 倍。只在**大文件**且上游声明支持
122
+ * `accept-ranges: bytes` 时走这条路,其它情况一律退回单流(简单、且小文件分片反而更慢)。
123
+ *
124
+ * 校验:各分片大小之和必须等于总长,否则整次失败(宁可重下也不留半截文件)。
125
+ */
126
+ async function fetchRanged(url, part, total, onChunk) {
127
+ const CHUNKS = 3;
128
+ const size = Math.ceil(total / CHUNKS);
129
+ const tasks = [];
130
+ for (let i = 0; i < CHUNKS; i += 1) {
131
+ const start = i * size;
132
+ const end = Math.min(total - 1, start + size - 1);
133
+ if (start > end) break;
134
+ tasks.push((async () => {
135
+ const piece = part + '.' + i;
136
+ const r = await fetch(url, {
137
+ headers: { 'user-agent': 'dsh-folia/download-queue', range: 'bytes=' + start + '-' + end },
138
+ redirect: 'follow',
139
+ });
140
+ if (r.status !== 206) throw new Error('range-unsupported-' + r.status);
141
+ mkdirSync(dirname(piece), { recursive: true });
142
+ const out = createWriteStream(piece, { flags: 'w' });
143
+ const src = r.body ? Readable.fromWeb(r.body) : null;
144
+ if (!src) throw new Error('empty-range-body');
145
+ await new Promise((resolve, reject) => {
146
+ src.on('data', (chunk) => {
147
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
148
+ onChunk(buf.length);
149
+ if (!out.write(buf)) { src.pause(); out.once('drain', () => src.resume()); }
150
+ });
151
+ src.on('end', resolve);
152
+ src.on('error', reject);
153
+ });
154
+ await new Promise((resolve) => { out.end(resolve); });
155
+ return piece;
156
+ })());
157
+ }
158
+ const pieces = await Promise.all(tasks);
159
+ // 拼接:按顺序读出各分片追加到 .part(最终仍是"完成才 rename"的原子语义)
160
+ mkdirSync(dirname(part), { recursive: true });
161
+ const sink = createWriteStream(part, { flags: 'w' });
162
+ let written = 0;
163
+ for (const piece of pieces) {
164
+ const buf = readFileSync(piece);
165
+ written += buf.length;
166
+ if (!sink.write(buf)) { await new Promise((resolve) => { sink.once('drain', resolve); }); }
167
+ try { rmSync(piece, { force: true }); } catch { /* ignore */ }
168
+ }
169
+ await new Promise((resolve) => { sink.end(resolve); });
170
+ if (written !== total) throw new Error('range-size-mismatch:' + written + '/' + total);
171
+ }
172
+
117
173
  async function runOne(job) {
118
174
  job.status = 'running';
119
175
  job.error = null;
@@ -127,12 +183,47 @@ export function createDownloadQueue(opts = {}) {
127
183
  if (from > 0) headers.range = 'bytes=' + from + '-';
128
184
  const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
129
185
  job.ctrl = ctrl;
186
+
187
+ // ③ 下载前先问一次"要不要下":HEAD 拿长度 + etag,与上次落地的 sidecar 记录一致 ⇒ 整次跳过。
188
+ // 省掉的是整包传输,收益比任何传输优化都大(站点换 CDN 但内容没变时也能认出来)。
189
+ if (from === 0 && existsSync(job.dest)) {
190
+ try {
191
+ const h = await fetch(job.url, { method: 'HEAD', redirect: 'follow' });
192
+ if (h.ok) {
193
+ const size = Number(h.headers.get('content-length') || 0);
194
+ const etag = h.headers.get('etag') || '';
195
+ let seen = null;
196
+ try { seen = JSON.parse(readFileSync(job.dest + '.etag', 'utf8')); } catch { seen = null; }
197
+ const same = seen && seen.size === size && size > 0 && (!etag || !seen.etag || seen.etag === etag);
198
+ if (same) {
199
+ setJob(job, { status: 'done', total: size, received: size });
200
+ try { onDone(job); } catch { /* 消费方失败不影响结果 */ }
201
+ return;
202
+ }
203
+ }
204
+ } catch { /* HEAD 失败就照常下载,不做判断 */ }
205
+ }
206
+
130
207
  const r = await fetch(job.url, { headers, signal: ctrl ? ctrl.signal : undefined, redirect: 'follow' });
131
208
  if (!r.ok && r.status !== 206) throw new Error('http-' + r.status);
132
209
  const startAt = r.status === 206 ? from : 0;
133
210
  if (startAt === 0 && existsSync(part)) { try { rmSync(part, { force: true }); } catch { /* ignore */ } }
134
211
  const len = Number(r.headers.get('content-length') || 0);
135
212
  setJob(job, { total: len ? len + startAt : (job.total || 0) });
213
+
214
+ // ② 大文件 + 支持 Range ⇒ 分片并发(单连接在高 RTT 线路上被窗口限制)。
215
+ const acceptsRanges = /bytes/i.test(String(r.headers.get('accept-ranges') || ''));
216
+ const totalKnown = Number(job.total) || 0;
217
+ if (startAt === 0 && acceptsRanges && totalKnown >= 8 * 1024 * 1024) {
218
+ try { if (r.body && typeof r.body.cancel === 'function') r.body.cancel(); } catch { /* ignore */ }
219
+ await fetchRanged(job.url, part, totalKnown, (n) => { job.received += n; });
220
+ renameSync(part, job.dest);
221
+ try { writeFileSync(job.dest + '.etag', JSON.stringify({ size: totalKnown, etag: '' })); } catch { /* ignore */ }
222
+ setJob(job, { status: 'done', received: totalKnown });
223
+ try { onDone(job); } catch { /* ignore */ }
224
+ return;
225
+ }
226
+
136
227
  mkdirSync(dirname(job.dest), { recursive: true });
137
228
  const out = createWriteStream(part, { flags: startAt > 0 ? 'a' : 'w' });
138
229
  const src = r.body ? Readable.fromWeb(r.body) : null;
@@ -152,6 +243,8 @@ export function createDownloadQueue(opts = {}) {
152
243
  renameSync(part, job.dest);
153
244
  job.status = 'done';
154
245
  if (!job.total) { try { job.total = statSync(job.dest).size; } catch { /* ignore */ } }
246
+ // 记一次 sidecar,供下次 HEAD 比对(内容没变就整次跳过)
247
+ try { writeFileSync(job.dest + '.etag', JSON.stringify({ size: job.total, etag: '' })); } catch { /* ignore */ }
155
248
  try { onDone(job); } catch { /* 消费方失败不影响下载结果 */ }
156
249
  log('download-queue: done ' + job.kind + ' ' + job.dest + ' (' + Math.round(job.total / 1048576) + ' MB)');
157
250
  } catch (e) {
@@ -27,7 +27,7 @@ function dshHome() {
27
27
 
28
28
  /** 进程内单例:接口与 inventory 共用同一个队列(并发上限/去重才有意义)。 */
29
29
  let QUEUE = null;
30
- function getDownloadQueue(log) {
30
+ export function getDownloadQueue(log) {
31
31
  if (!QUEUE) QUEUE = createDownloadQueue({ home: dshHome(), log: log || (() => {}) });
32
32
  return QUEUE;
33
33
  }
@@ -23,6 +23,8 @@ import { inflateRawSync } from 'node:zlib';
23
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
+ // 皮肤包的下载走与壁纸同一条队列(Range 续传 / 去重 / 并发上限),落地后再解压。
27
+ import { getDownloadQueue } from './downloads.js';
26
28
 
27
29
  /** 注入契约(ADR-0013 D12)。 */
28
30
  export { unzipSkinZip, safeSkinId };
@@ -598,11 +600,29 @@ export function registerSkinsRoutes(webServer, c) {
598
600
 
599
601
  let buf;
600
602
  try {
601
- const r = await fetch(url.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
602
- if (!r.ok) { sendJson(res, 502, { ok: false, error: 'http-' + r.status }); return; }
603
- const len = Number(r.headers.get('content-length') || 0);
604
- if (len && len > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
605
- buf = Buffer.from(await r.arrayBuffer());
603
+ // ① 皮肤包改走**下载队列**(与壁纸同一条链路):Range 续传、断线可接着下、
604
+ // 落地后再从磁盘读来解压 —— 不再"网络层整包进内存且无法续传"。
605
+ // 说明:解压仍需完整 Buffer(zip 中央目录在尾部,流式解压是另一个量级的改动),
606
+ // 这里省掉的是重复内存与不可续传这两件事。
607
+ const zipDest = join(dshHome(), 'cache', 'skins-zip',
608
+ createHash('sha256').update(url.toString()).digest('hex').slice(0, 32) + '.zip');
609
+ const q = getDownloadQueue();
610
+ const added = q.add({ url: url.toString(), dest: zipDest, kind: 'skin' });
611
+ const jobId = added && added.job ? added.job.id : null;
612
+ const deadline = Date.now() + 10 * 60 * 1000;
613
+ let state = added && added.job ? added.job : null;
614
+ while (Date.now() < deadline) {
615
+ state = q.snapshot().find((j) => j.id === jobId) || state;
616
+ if (state && state.status === 'done') break;
617
+ if (state && (state.status === 'failed' || state.status === 'cancelled')) {
618
+ sendJson(res, 502, { ok: false, error: state.error || 'download-failed' });
619
+ return;
620
+ }
621
+ await new Promise((resolve) => { setTimeout(resolve, 150); });
622
+ }
623
+ const total = Number(state && state.total) || 0;
624
+ if (total && total > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
625
+ buf = readFileSync(zipDest);
606
626
  if (buf.length > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
607
627
  } catch (e) {
608
628
  sendJson(res, 502, { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : 'network' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.18.0",
3
+ "version": "4.19.1",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {