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 +3 -1
- package/lib/download-queue.js +93 -0
- package/lib/routes/downloads.js +1 -1
- package/lib/routes/skins.js +25 -5
- package/package.json +2 -1
package/cordis.patch.yml
CHANGED
package/lib/download-queue.js
CHANGED
|
@@ -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) {
|
package/lib/routes/downloads.js
CHANGED
|
@@ -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
|
}
|
package/lib/routes/skins.js
CHANGED
|
@@ -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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
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
|
},
|