feat: 增加下载任务中心与断点续传

This commit is contained in:
lofyer
2026-08-05 15:25:26 +08:00
parent f72c26642f
commit cb7b020dc8
20 changed files with 1094 additions and 70 deletions
+246 -3
View File
@@ -20,10 +20,15 @@ const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
`unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
));
const rangedChunks = Array.from({ length: 12 }, (_unused, index) => Buffer.from(
`ranged-chunk-${index}-` + String.fromCharCode(75 + (index % 10)).repeat(16 * 1024)
));
const knownPayload = Buffer.concat(knownChunks);
const unknownPayload = Buffer.concat(unknownChunks);
const rangedPayload = Buffer.concat(rangedChunks);
const results = [];
const rangedRequests = [];
let server;
let testWindow;
@@ -35,6 +40,15 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForRenderer(expression, timeout = 8000) {
const started = Date.now();
while (Date.now() - started < timeout) {
if (await testWindow.webContents.executeJavaScript(`Boolean(${expression})`)) return true;
await wait(50);
}
return false;
}
function serveChunks(response, chunks, contentLength) {
const headers = {
'Content-Type': 'text/plain; charset=utf-8',
@@ -58,6 +72,46 @@ function serveChunks(response, chunks, contentLength) {
sendNext();
}
function serveRange(request, response) {
const match = String(request.headers.range || '').match(/^bytes=(\d+)-$/);
const start = match ? Number(match[1]) : 0;
rangedRequests.push({ url: request.url, start });
if (!Number.isSafeInteger(start) || start < 0 || start >= rangedPayload.length) {
response.writeHead(416, {
'Content-Range': `bytes */${rangedPayload.length}`,
Connection: 'close'
});
response.end();
return;
}
const chunks = [];
for (let offset = start; offset < rangedPayload.length; offset += 16 * 1024) {
chunks.push(rangedPayload.subarray(offset, Math.min(rangedPayload.length, offset + 16 * 1024)));
}
const headers = {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename="ranged-fixture.txt"',
'Content-Length': String(rangedPayload.length - start),
'Accept-Ranges': 'bytes',
ETag: '"ranged-fixture-v1"',
Connection: 'close'
};
if (start) headers['Content-Range'] = `bytes ${start}-${rangedPayload.length - 1}/${rangedPayload.length}`;
response.writeHead(start ? 206 : 200, headers);
if (response.socket) response.socket.setNoDelay(true);
let index = 0;
const sendNext = () => {
if (response.destroyed || response.writableEnded) return;
if (index >= chunks.length) {
response.end();
return;
}
response.write(chunks[index++]);
setTimeout(sendNext, 130);
};
sendNext();
}
function monotonic(events) {
return events.every((event, index) => {
const current = Number(event.receivedBytes);
@@ -139,6 +193,8 @@ async function run() {
serveChunks(response, knownChunks, knownPayload.length);
} else if (request.url === '/unknown.txt') {
serveChunks(response, unknownChunks, null);
} else if (request.url.startsWith('/range.txt')) {
serveRange(request, response);
} else {
response.writeHead(404, { Connection: 'close' });
response.end('not found');
@@ -210,7 +266,12 @@ async function run() {
})()`);
check('preload 暴露下载 API',
await testWindow.webContents.executeJavaScript('typeof window.api.downloadFile === "function"'));
await testWindow.webContents.executeJavaScript(
'typeof window.api.downloadFile === "function"'
+ ' && typeof window.api.downloads.run === "function"'
+ ' && typeof window.api.downloads.pause === "function"'
+ ' && typeof window.api.downloads.delete === "function"'
));
const port = server.address().port;
const known = await downloadInRenderer(
@@ -294,6 +355,188 @@ async function run() {
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
unknownEntry && unknownEntry.id);
const directPageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
await testWindow.loadFile(path.join(ROOT, 'src', 'ui', 'index.html'));
await testWindow.webContents.executeJavaScript(`(() => {
window.__pageErrors = [];
addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
})()`);
const centerReady = await waitForRenderer(
'window.DownloadCenter && document.getElementById("taskCenterBtn").onclick'
);
check('主界面加载任务中心', centerReady);
const centerStarted = await testWindow.webContents.executeJavaScript(`(() => {
window.__centerResult = null;
window.DownloadCenter.start({
key: 'integration-center-download',
url: ${JSON.stringify(`http://127.0.0.1:${port}/known.txt`)},
suggestName: 'center-fixture.txt',
meta: {
title: 'Task Center Download',
authors: ['Integration Fixture'],
sourceId: 'download-test',
sourcePostId: 'center'
}
}).then((result) => { window.__centerResult = result; });
document.getElementById('taskCenterBtn').click();
return true;
})()`);
check('任务中心可发起下载', centerStarted);
const centerRunning = await waitForRenderer(
'document.querySelector(".task-center-item.running")'
);
check('任务中心显示进行中任务', centerRunning);
check('进行中任务显示实时字节进度',
await waitForRenderer(
'document.querySelector(".task-center-item.running .task-center-status")'
+ ' && /已下载|%/.test(document.querySelector(".task-center-item.running .task-center-status").textContent)'
));
await testWindow.webContents.executeJavaScript(
'document.querySelector(".tab[data-tab=\\"settings\\"]").click()'
);
check('切换页面后任务中心仍保留下载',
await testWindow.webContents.executeJavaScript(
'!document.getElementById("settingsTab").classList.contains("hidden")'
+ ' && !!document.querySelector(".task-center-item.running")'
));
const centerComplete = await waitForRenderer(
'window.__centerResult && document.querySelector(".task-center-item.complete")',
10000
);
check('切换页面后下载继续并完成', centerComplete);
const centerState = await testWindow.webContents.executeJavaScript(`(() => {
const item = document.querySelector('.task-center-item.complete');
return {
result: window.__centerResult,
state: item && item.querySelector('.task-center-state').textContent,
hasOpen: !!(item && item.querySelector('[data-task-action="open"]')),
hasReveal: !!(item && item.querySelector('[data-task-action="reveal"]'))
};
})()`);
check('已完成任务提供打开与定位入口',
centerState.state === '已完成' && centerState.hasOpen && centerState.hasReveal,
JSON.stringify(centerState));
const centerPath = centerState.result && centerState.result.ok && centerState.result.data.path;
check('任务中心下载字节完全一致',
!!centerPath && fs.existsSync(centerPath) && fs.readFileSync(centerPath).equals(knownPayload),
centerPath || '');
const centerEntry = centerState.result && centerState.result.ok
? library.get(centerState.result.data.entryId) : null;
check('任务中心下载自动挂载到书库',
!!centerEntry && centerEntry.title === 'Task Center Download'
&& centerEntry.files.some((file) => file.path === centerPath && file.exists),
centerEntry && centerEntry.id);
await testWindow.webContents.executeJavaScript(`(() => {
window.__resumeFirst = null;
window.DownloadCenter.start({
key: 'integration-resume-download',
url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?resume=1`)},
suggestName: 'resume-fixture.txt',
meta: {
title: 'Resume Download',
authors: [],
sourceId: 'download-test',
sourcePostId: 'resume'
}
}).then((result) => { window.__resumeFirst = result; });
})()`);
const resumeProgress = await waitForRenderer(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
return item && item.classList.contains('running')
&& parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
})()`);
check('可续传任务开始下载并产生进度', resumeProgress);
await testWindow.webContents.executeJavaScript(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
item.querySelector('[data-task-action="pause"]').click();
})()`);
const paused = await waitForRenderer(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
return item && item.classList.contains('paused')
&& item.querySelector('.task-center-state').textContent === '已暂停'
&& item.querySelector('[data-task-action="resume"]');
})()`);
check('任务中心可暂停未完成下载', paused);
const filesDir = path.join(LIBRARY_DIR, 'files');
const pausedParts = fs.readdirSync(filesDir).filter((name) => name.endsWith('.part'));
const pausedPart = pausedParts.length === 1 ? path.join(filesDir, pausedParts[0]) : '';
const pausedSize = pausedPart && fs.existsSync(pausedPart) ? fs.statSync(pausedPart).size : 0;
check('暂停保留未完成文件作为续传断点',
pausedParts.length === 1 && pausedSize > 0 && pausedSize < rangedPayload.length,
`文件=${pausedParts.join(',')} 大小=${pausedSize}`);
await testWindow.webContents.executeJavaScript(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
item.querySelector('[data-task-action="resume"]').click();
})()`);
const resumed = await waitForRenderer(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
return item && item.classList.contains('complete');
})()`, 10000);
check('任务中心可继续已暂停下载并完成', resumed);
const resumeRequests = rangedRequests.filter((request) => request.url.includes('resume=1'));
check('继续下载从临时文件末尾发送 Range',
resumeRequests.length >= 2 && resumeRequests[1].start === pausedSize && pausedSize > 0,
JSON.stringify(resumeRequests));
const resumeEntry = library.findBySource('download-test', 'resume');
const resumePath = resumeEntry && resumeEntry.files[0] && resumeEntry.files[0].path;
check('断点续传后的文件字节完全一致',
!!resumePath && fs.existsSync(resumePath)
&& fs.readFileSync(resumePath).equals(rangedPayload),
resumePath || '');
check('断点续传完成后清理临时文件',
fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
await testWindow.webContents.executeJavaScript(`(() => {
window.__deleteResult = null;
window.DownloadCenter.start({
key: 'integration-delete-download',
url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?delete=1`)},
suggestName: 'delete-fixture.txt',
meta: {
title: 'Delete Download',
authors: [],
sourceId: 'download-test',
sourcePostId: 'delete'
}
}).then((result) => { window.__deleteResult = result; });
})()`);
check('待删除任务先产生部分内容',
await waitForRenderer(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
return item && item.classList.contains('running')
&& parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
})()`));
await testWindow.webContents.executeJavaScript(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
item.querySelector('[data-task-action="delete"]').click();
})()`);
const deleted = await waitForRenderer(`(() => {
const item = [...document.querySelectorAll('.task-center-item')]
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
return !item && window.__deleteResult && window.__deleteResult.ok
&& window.__deleteResult.data.deleted === true;
})()`);
check('任务中心可删除进行中的下载', deleted);
await wait(150);
check('删除未完成任务会清理临时文件',
fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
check('删除未完成任务不会创建书库条目',
!library.findBySource('download-test', 'delete'));
const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
const backgroundValue = declaration(downloadedRule, 'background');
@@ -313,8 +556,8 @@ async function run() {
await wait(100);
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
check('下载流程没有渲染器错误',
rendererErrors.length === 0 && pageErrors.length === 0,
rendererErrors.concat(pageErrors).join(' | '));
rendererErrors.length === 0 && directPageErrors.length === 0 && pageErrors.length === 0,
rendererErrors.concat(directPageErrors, pageErrors).join(' | '));
} catch (error) {
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
} finally {
+6
View File
@@ -285,6 +285,12 @@ test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/);
assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/);
assert.match(mainSrc, /headers\['Range'\]\s*=\s*`bytes=\$\{resumeBytes\}-`/);
assert.match(mainSrc, /res\.status\s*===\s*206/);
assert.match(mainSrc, /ipcMain\.handle\('download:pause'/);
assert.match(mainSrc, /ipcMain\.handle\('download:delete'/);
assert.match(mainSrc, /downloadSessionKey\(event\.sender\.id,\s*id\)/);
assert.match(mainSrc, /removeDownloadPartial\(download\)/);
});
test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
+24 -1
View File
@@ -165,9 +165,32 @@ test('书库页提供可管理标签目录和整理多选下拉', () => {
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
});
test('下载区展示进度且完成按钮使用高对比绿色底色', () => {
test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
const center = fs.readFileSync(path.join(__dirname, '..', 'ui', 'download-center.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(html, /id="taskCenterBtn"/);
assert.match(html, /id="taskCenterPanel"/);
assert.ok(html.indexOf('download-center.js') < html.indexOf('views/browse.js'));
assert.match(app, /DownloadCenter\.init\(\)/);
assert.match(browse, /window\.DownloadCenter\.start\(\{/);
assert.doesNotMatch(browse, /await window\.api\.downloadFile/);
assert.match(center, /window\.api\.downloads\.run\(/);
assert.match(center, /window\.api\.downloads\.pause\(/);
assert.match(center, /window\.api\.downloads\.delete\(/);
assert.match(center, /data-task-action="pause"/);
assert.match(center, /data-task-action="resume"/);
assert.match(center, /data-task-action="delete"/);
assert.match(preload, /pause:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:pause'/);
assert.match(preload, /delete:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:delete'/);
assert.match(center, /task\.status = 'complete'/);
assert.match(center, /task\.status = 'failed'/);
assert.match(center, /data-task-action="open"/);
assert.match(css, /\.task-center-panel\s*\{/);
assert.match(css, /\.task-center-badge\s*\{/);
assert.match(browse, /createDownloadProgress/);
assert.match(browse, /updateDownloadProgress/);
assert.match(browse, /classList\.add\('downloaded'\)/);