dsh-folia 4.17.1 → 4.19.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
@@ -27,4 +27,6 @@
27
27
  - id: folia
28
28
  name: 'dsh-folia'
29
29
  - id: skin-center
30
- name: '@linxin666/dsh-client-ui-skin-center'
30
+ name: '@linxin666/dsh-client-ui-skin-center'
31
+ - id: market
32
+ name: '@linxin666/dsh-client-ui-market'
@@ -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.17.1",
3
+ "version": "4.19.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -195,6 +195,7 @@
195
195
  },
196
196
  "dependencies": {
197
197
  "@linxin666/dsh-client-ui-skin-center": "0.4.12",
198
+ "@linxin666/dsh-client-ui-market": "0.4.12",
198
199
  "webwallgl": "^3.0.0",
199
200
  "x11": "^4.3.1"
200
201
  },