diff --git a/BUILD.md b/BUILD.md index 89684a1..eccd844 100644 --- a/BUILD.md +++ b/BUILD.md @@ -19,7 +19,7 @@ npm run portable 输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。 -发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v1.3.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。 +发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v2.0.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。 ### macOS(Apple Silicon) @@ -45,6 +45,56 @@ xattr -dr com.apple.quarantine /Applications/PeopleLib.app 图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`。 +## 固定构建变体 + +PeopleLib 采用固定构建变体,不使用远程开关在应用发布后改变功能范围: + +| 构建 | 定位 | 功能范围 | +|---|---|---| +| 桌面完整版 | Windows、macOS | 多源检索、下载与任务中心、数据源账号、代理、本地书库、阅读和笔记 | +| 移动阅读版 | iPadOS、Android | 本地导入、书库、阅读、笔记、书签和批注 | + +移动阅读版固定关闭以下能力: + +```js +{ + remoteSearch: false, + remoteDownload: false, + sourceAccounts: false, + proxy: false +} +``` + +这些能力必须在构建时确定。移动端不展示相关入口、不注册对应桥接 API、不发起数据源请求,也不能通过服务端配置重新开启。桌面端继续保留完整功能。 + +移动端不能直接复用 Electron 包,需要使用独立的移动端外壳。下载、文件系统、安全存储和阅读文件访问均应通过 iPadOS/Android 原生桥接实现。首个移动版本只提供本地阅读能力;从系统文件选择器、分享面板或用户自行管理的云盘导入文件,不提供应用内在线检索和下载。 + +移动阅读版必须由专用脚本生成,不能依赖开发者手工删除页面或模块。计划提供以下固定入口: + +```bash +npm run build:android +npm run build:ios +``` + +两个命令应调用同一套移动构建脚本,并把平台与固定变体显式传入,例如: + +```bash +node build-mobile.js --platform android --variant reader-only +node build-mobile.js --platform ios --variant reader-only +``` + +`build-mobile.js` 必须完成: + +1. 校验变体只能是 `reader-only`,拒绝从环境变量或远程配置开启受限能力。 +2. 生成移动端能力清单,并在编译前固定关闭检索、下载、数据源账号和代理。 +3. 使用移动端入口组装资源,不注册桌面端 IPC,不复制在线数据源模块。 +4. 调用 Android 或 iOS 原生工程构建工具,并把产物输出到固定的 `dist/PeopleLib-android/` 或 `dist/PeopleLib-ios/`。 +5. 对最终产物运行自动化检查,确认不存在检索、下载、数据源账号和代理入口。 + +Android 构建可以在 Windows、macOS 或 Linux 上执行;iOS/iPadOS 构建依赖 Xcode、签名与 Apple SDK,只能在 macOS 上执行。 + +当前仓库尚未包含 `build-mobile.js` 与移动端原生工程,所以上述命令是必须补齐的目标构建入口,目前不能生成移动安装包。 + ## 配置 ### 代理 diff --git a/README.md b/README.md index 8bf21a0..f6ec2f8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ AI 助手,可把选中文本、当前页、全文或框选区域作为上下 - **多源检索**:12 个数据源统一的搜索、详情、下载流程 - **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理 +- **任务中心**:全局查看下载进度,离开详情页后继续下载,支持暂停、断点续传和删除未完成任务 - **内置阅读器**:PDF、EPUB、无 DRM 的 MOBI/KF7/KF8 与 TXT/Markdown 阅读,支持进度、书签、选文和笔记 - **全局代理**:一处配置,对所有数据源与封面请求生效 - **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用 @@ -69,7 +70,7 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不 2. 双击目录中的 `PeopleLib.exe`。 3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。 -当前版本为 **1.3.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。 +当前版本为 **2.0.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。 ## 开发 diff --git a/main.js b/main.js index 91b06ee..a12e092 100644 --- a/main.js +++ b/main.js @@ -150,6 +150,8 @@ const legacyImportPending = !settings.get('legacyImported', false); let mainWindow; let activeDownloads = 0; +const downloadSessions = new Map(); +const downloadSessionSenders = new Set(); let readerPurgeSeq = 0; let startupMaintenanceStarted = false; const readerPurgeWaiters = new Map(); @@ -177,6 +179,40 @@ function ensureReaderWritable(entryId) { if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除'); } +function downloadSessionKey(senderId, requestId) { + return `${senderId}:${requestId}`; +} + +function removeDownloadPartial(session) { + if (!session || !session.partial) return; + try { + fs.unlinkSync(session.partial); + session.partial = ''; + } catch (e) { + if (e.code === 'ENOENT') session.partial = ''; + } +} + +function discardDownloadSession(session) { + if (!session) return; + session.control = 'delete'; + if (session.controller) session.controller.abort(); + removeDownloadPartial(session); + if (session.state !== 'running') downloadSessions.delete(session.key); +} + +function trackDownloadSender(webContents) { + const senderId = webContents.id; + if (downloadSessionSenders.has(senderId)) return; + downloadSessionSenders.add(senderId); + webContents.once('destroyed', () => { + downloadSessionSenders.delete(senderId); + for (const session of downloadSessions.values()) { + if (session.senderId === senderId) discardDownloadSession(session); + } + }); +} + function createWindow() { mainWindow = new BrowserWindow({ width: 1240, @@ -276,6 +312,7 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { + for (const download of downloadSessions.values()) discardDownloadSession(download); coverGenerator.close(); rangeSessions.closeAll().catch(() => {}); }); @@ -439,7 +476,7 @@ ipcMain.handle('library:pickDir', async () => { ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => { const dest = String(dir || '').trim(); if (!dest) throw new Error('目录不能为空'); - if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录'); + if (activeDownloads || downloadSessions.size) throw new Error('请等待当前下载完成或删除未完成任务后再切换书库目录'); const previousSetting = settings.get('libraryDir', ''); const previousRoot = library.getRoot(); try { @@ -624,11 +661,17 @@ ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => { // 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。 // meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。 ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => { + const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : ''; + if (!/^[A-Za-z0-9_-]{1,100}$/.test(progressId)) return { ok: false, error: '下载任务 ID 无效' }; + const key = downloadSessionKey(event.sender.id, progressId); + let download = downloadSessions.get(key); + if (download && download.state === 'running') return { ok: false, error: '该下载任务正在运行' }; + if (download && download.control === 'delete') return { ok: false, error: '该下载任务已删除' }; + trackDownloadSender(event.sender); + activeDownloads++; - let partial = ''; let res = null; let bodyHandled = false; - const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : ''; const sendProgress = (data) => { if (!progressId || event.sender.isDestroyed()) return; event.sender.send('download:progress', { requestId: progressId, ...data }); @@ -638,22 +681,65 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接'); const askSavePath = settings.get('askSavePath', false); const suggested = library.sanitize(suggestName || 'download.bin'); - let target; - if (askSavePath) { + if (!download) { + download = { + key, + senderId: event.sender.id, + requestId: progressId, + state: 'preparing', + control: '', + controller: null, + partial: '', + target: '', + defaultName: '', + askSavePath, + receivedBytes: 0, + totalBytes: null, + validator: '', + url: parsedUrl.toString(), + suggestName: String(suggestName || ''), + entryId, + extraHeaders: { ...(extraHeaders || {}) }, + meta + }; + downloadSessions.set(key, download); + } + if (download.url !== parsedUrl.toString()) throw new Error('续传链接与原任务不一致'); + + if (download.askSavePath && !download.target) { const save = await dialog.showSaveDialog(liveWindow(), { title: '保存文件', defaultPath: path.join(library.filesDir(), suggested) }); - if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } }; - target = save.filePath; + if (save.canceled || !save.filePath) { + downloadSessions.delete(key); + return { ok: true, data: { canceled: true } }; + } + download.target = save.filePath; + } + if (download.control === 'delete') { + downloadSessions.delete(key); + return { ok: true, data: { deleted: true } }; } - const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) }; + const headers = { 'User-Agent': DL_UA, ...download.extraHeaders }; headers['Referer'] = parsedUrl.origin + '/'; + const resumeBytes = download.partial && fs.existsSync(download.partial) + ? fs.statSync(download.partial).size + : 0; + download.receivedBytes = resumeBytes; + if (resumeBytes > 0) { + headers['Range'] = `bytes=${resumeBytes}-`; + if (download.validator) headers['If-Range'] = download.validator; + } + const ac = new AbortController(); + download.controller = ac; + download.control = ''; + download.state = 'running'; const timer = setTimeout(() => ac.abort(), 30000); try { - res = await fetchWithProxy(parsedUrl.toString(), { + res = await fetchWithProxy(download.url, { redirect: 'follow', headers, signal: ac.signal @@ -661,30 +747,58 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe } finally { clearTimeout(timer); } + if (download.control) throw new Error('下载已中止'); if (!res.ok) throw new Error(`下载失败: ${res.status}`); - const declaredSize = Number(res.headers.get('content-length')); - const totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null; - let receivedBytes = 0; - let lastProgressAt = 0; - sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? 0 : null }); - const respName = filenameFromResponse(res, suggestName); - const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName); - const defaultName = library.sanitize(hasExt ? suggestName : respName); + const resumed = resumeBytes > 0 && res.status === 206; + if (resumeBytes > 0 && !resumed) { + removeDownloadPartial(download); + download.receivedBytes = 0; + } + const declaredSize = Number(res.headers.get('content-length')); + let totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 + ? declaredSize + (resumed ? resumeBytes : 0) + : null; + const contentRange = res.headers.get('content-range') || ''; + const rangeMatch = contentRange.match(/^bytes\s+(\d+)-\d+\/(\d+|\*)$/i); + if (resumed && (!rangeMatch || Number(rangeMatch[1]) !== resumeBytes)) { + throw new Error('远端服务器返回了错误的断点位置,请删除任务后重新下载'); + } + if (rangeMatch && rangeMatch[2] !== '*') totalBytes = Number(rangeMatch[2]); + if (resumed && download.totalBytes && totalBytes && download.totalBytes !== totalBytes) { + throw new Error('远端文件在暂停期间发生变化,请删除任务后重新下载'); + } + if (!resumed) download.validator = res.headers.get('etag') || res.headers.get('last-modified') || ''; + download.totalBytes = totalBytes; + let receivedBytes = resumed ? resumeBytes : 0; + let lastProgressAt = 0; + sendProgress({ + receivedBytes, + totalBytes, + percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null + }); + + const respName = filenameFromResponse(res, download.suggestName); + const hasExt = download.suggestName && /\.[a-z0-9]{2,5}$/i.test(download.suggestName); + if (!download.defaultName) { + download.defaultName = library.sanitize(hasExt ? download.suggestName : respName); + } const contentType = res.headers.get('content-type') || ''; - const expectedExt = path.extname(target || defaultName).toLowerCase(); + const expectedExt = path.extname(download.target || download.defaultName).toLowerCase(); if (/text\/html|application\/json/i.test(contentType) && !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) { throw new Error('下载地址返回了网页而不是文献文件'); } - if (!target) target = library.allocFilePath(defaultName); + if (!download.target) download.target = library.allocFilePath(download.defaultName); - fs.mkdirSync(path.dirname(target), { recursive: true }); - partial = path.join( - path.dirname(target), - `.${path.basename(target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part` - ); + fs.mkdirSync(path.dirname(download.target), { recursive: true }); + if (!download.partial) { + download.partial = path.join( + path.dirname(download.target), + `.${path.basename(download.target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part` + ); + } if (!res.body) throw new Error('下载响应没有文件内容'); let transferTimer; const refreshTransferTimer = () => { @@ -695,6 +809,7 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe transform(chunk, encoding, callback) { refreshTransferTimer(); receivedBytes += chunk.length; + download.receivedBytes = receivedBytes; const now = Date.now(); if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) { lastProgressAt = now; @@ -710,71 +825,127 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe refreshTransferTimer(); bodyHandled = true; try { - await pipeline(Readable.fromWeb(res.body), activity, fs.createWriteStream(partial, { flags: 'wx' })); + await pipeline( + Readable.fromWeb(res.body), + activity, + fs.createWriteStream(download.partial, { flags: resumed ? 'a' : 'wx' }) + ); } finally { clearTimeout(transferTimer); } - if (askSavePath) { - const backup = `${target}.${process.pid}-${Date.now()}.bak`; + if (download.control) throw new Error('下载已中止'); + + if (download.askSavePath) { + const backup = `${download.target}.${process.pid}-${Date.now()}.bak`; let backedUp = false; try { - if (fs.existsSync(target)) { - fs.renameSync(target, backup); + if (fs.existsSync(download.target)) { + fs.renameSync(download.target, backup); backedUp = true; } - fs.renameSync(partial, target); - partial = ''; + fs.renameSync(download.partial, download.target); + download.partial = ''; if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ } } } catch (e) { try { - if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target); + if (backedUp && !fs.existsSync(download.target) && fs.existsSync(backup)) { + fs.renameSync(backup, download.target); + } } catch (rollbackError) { /* ignore */ } throw e; } } else { for (;;) { try { - fs.linkSync(partial, target); + fs.linkSync(download.partial, download.target); break; } catch (e) { if (e.code !== 'EEXIST') throw e; - target = library.allocFilePath(defaultName); + download.target = library.allocFilePath(download.defaultName); } } - try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ } - partial = ''; + try { fs.unlinkSync(download.partial); } catch (e) { /* 保留硬链接副本不影响文件 */ } + download.partial = ''; } sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true }); // 落库:优先挂到已有条目,否则用 meta 新建 - let id = entryId; - if (!id && meta) { - const existing = meta.sourceId && meta.sourcePostId - ? library.findBySource(meta.sourceId, meta.sourcePostId) : null; - id = existing ? existing.id : library.add(meta).id; + let id = download.entryId; + if (!id && download.meta) { + const existing = download.meta.sourceId && download.meta.sourcePostId + ? library.findBySource(download.meta.sourceId, download.meta.sourcePostId) : null; + id = existing ? existing.id : library.add(download.meta).id; } - const entry = id ? library.attachFile(id, target) : null; + const entry = id ? library.attachFile(id, download.target) : null; if (entry) coverGenerator.ensure(entry.id).catch(() => {}); - return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } }; + downloadSessions.delete(key); + return { + ok: true, + data: { + path: download.target, + name: path.basename(download.target), + entryId: id || null, + entry, + receivedBytes, + totalBytes + } + }; } catch (e) { if (res && res.body && !bodyHandled) { try { await res.body.cancel(); } catch (cancelError) { /* ignore */ } } - if (partial) { - try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ } + if (download && download.control === 'pause') { + download.state = 'paused'; + download.controller = null; + if (download.partial && fs.existsSync(download.partial)) { + download.receivedBytes = fs.statSync(download.partial).size; + } + return { + ok: true, + data: { + paused: true, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes + } + }; } + if (download && download.control === 'delete') { + removeDownloadPartial(download); + downloadSessions.delete(key); + return { ok: true, data: { deleted: true } }; + } + removeDownloadPartial(download); + downloadSessions.delete(key); if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) { return { ok: false, error: '下载超时,请检查网络或代理设置' }; } return { ok: false, error: e.message || String(e) }; } finally { + if (download) download.controller = null; activeDownloads--; } }); +ipcMain.handle('download:pause', (event, requestId) => wrap(() => { + const id = typeof requestId === 'string' ? requestId.slice(0, 100) : ''; + const download = downloadSessions.get(downloadSessionKey(event.sender.id, id)); + if (!download || download.state !== 'running' || !download.controller) return false; + download.control = 'pause'; + download.controller.abort(); + return true; +})); + +ipcMain.handle('download:delete', (event, requestId) => wrap(() => { + const id = typeof requestId === 'string' ? requestId.slice(0, 100) : ''; + const download = downloadSessions.get(downloadSessionKey(event.sender.id, id)); + if (!download) return false; + discardDownloadSession(download); + return true; +})); + // 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次, // 在资源管理器里定位该文件,而不是静默失败。 ipcMain.handle('shell:openPath', async (_e, p) => { diff --git a/package-lock.json b/package-lock.json index 7f5d3b9..35e751b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "peoplelib", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "peoplelib", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "foliate-js": "1.0.1", diff --git a/package.json b/package.json index f1d7ab9..575270b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "peoplelib", - "version": "1.3.0", + "version": "2.0.0", "description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library)", "main": "main.js", "author": "peoplelib", diff --git a/preload.js b/preload.js index 5f61983..f66e56f 100644 --- a/preload.js +++ b/preload.js @@ -1,8 +1,7 @@ const { contextBridge, ipcRenderer } = require('electron'); let downloadSeq = 0; -function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) { - const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`; +function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) { const listener = (_event, data) => { if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return; try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ } @@ -13,6 +12,11 @@ function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) .finally(() => ipcRenderer.removeListener('download:progress', listener)); } +function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) { + const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`; + return runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress); +} + function captureReaderRect(rect) { const value = rect && typeof rect === 'object' ? rect : {}; const area = { @@ -89,6 +93,11 @@ contextBridge.exposeInMainWorld('api', { } }, downloadFile, + downloads: { + run: runDownload, + pause: (requestId) => ipcRenderer.invoke('download:pause', requestId), + delete: (requestId) => ipcRenderer.invoke('download:delete', requestId) + }, zlib: { hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'), login: (email, password) => ipcRenderer.invoke('zlib:login', email, password), diff --git a/site/DEPLOY.md b/site/DEPLOY.md index 5ad660a..96dfebe 100644 --- a/site/DEPLOY.md +++ b/site/DEPLOY.md @@ -260,7 +260,7 @@ DNS 变更最多需要 24 小时传播(查证)。多数情况几分钟就好 ### 版本号会过期 -页脚、首页与下载页都写了 **1.3.0**,取自 `package.json` 的 `version` 字段。发新版时记得改,一共出现在这几处: +页脚、首页与下载页都写了 **2.0.0**,取自 `package.json` 的 `version` 字段。发新版时记得改,一共出现在这几处: ```powershell Select-String -Path D:\my_git\peoplelib\site\*.html -Pattern '1\.3\.0' diff --git a/site/download.html b/site/download.html index d703abd..53b5882 100644 --- a/site/download.html +++ b/site/download.html @@ -56,7 +56,7 @@

获取 PeopleLib

所有正式版本都通过项目的 GitHub Releases 发布,不通过 npm 或应用商店分发。 - 当前版本 1.3.0,MIT 许可,免费。 + 当前版本 2.0.0,MIT 许可,免费。

@@ -222,7 +222,7 @@

版本

diff --git a/site/faq.html b/site/faq.html index d9ad541..2a0f3a4 100644 --- a/site/faq.html +++ b/site/faq.html @@ -315,7 +315,7 @@

版本

diff --git a/site/features.html b/site/features.html index af3b145..eb927bc 100644 --- a/site/features.html +++ b/site/features.html @@ -306,7 +306,7 @@

版本

diff --git a/site/index.html b/site/index.html index b605cb0..b2f131b 100644 --- a/site/index.html +++ b/site/index.html @@ -64,7 +64,7 @@ 下载 PeopleLib 查看功能

-

当前版本 1.3.0,MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。

+

当前版本 2.0.0,MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。

PeopleLib 书库界面,左侧是书架与标签,右侧网格展示带封面的图书条目 @@ -265,7 +265,7 @@

版本

diff --git a/site/privacy.html b/site/privacy.html index e5c876e..5200e48 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -277,7 +277,7 @@

版本

diff --git a/src/_test/electron/download.integration.js b/src/_test/electron/download.integration.js index 63541f4..958a9d2 100644 --- a/src/_test/electron/download.integration.js +++ b/src/_test/electron/download.integration.js @@ -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 { diff --git a/src/_test/main.test.js b/src/_test/main.test.js index 1422510..de11ea7 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -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 主题图标并同步界面主题', () => { diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index 457c9b9..2ce9354 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -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'\)/); diff --git a/src/ui/app.js b/src/ui/app.js index 69aa53f..e675ff1 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -45,6 +45,7 @@ document.querySelectorAll('.tab').forEach((t) => { t.onclick = () => switchTab(t.dataset.tab); }); +DownloadCenter.init(); Browse.init(); Library.init(); Notes.init(); diff --git a/src/ui/download-center.js b/src/ui/download-center.js new file mode 100644 index 0000000..5650aff --- /dev/null +++ b/src/ui/download-center.js @@ -0,0 +1,341 @@ +(() => { + const tasks = []; + const MAX_HISTORY = 30; + let seq = 0; + let button = null; + let badge = null; + let panel = null; + let list = null; + let summary = null; + let clearButton = null; + + function formatBytes(value) { + const bytes = Math.max(0, Number(value) || 0); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + function isActive(task) { + return ['pending', 'running', 'pausing', 'deleting'].includes(task.status); + } + + function isUnfinished(task) { + return isActive(task) || task.status === 'paused'; + } + + function statusText(task) { + if (task.status === 'pending') return '准备下载…'; + if (task.status === 'pausing') return '正在暂停…'; + if (task.status === 'paused') { + return task.receivedBytes ? `已暂停 · ${formatBytes(task.receivedBytes)}` : '已暂停'; + } + if (task.status === 'deleting') return '正在删除…'; + if (task.status === 'failed') return task.error || '下载失败'; + if (task.status === 'canceled') return '已取消'; + if (task.status === 'complete') { + return task.receivedBytes ? `下载完成 · ${formatBytes(task.receivedBytes)}` : '下载完成'; + } + if (task.totalBytes && task.percent != null) { + return `${Math.round(task.percent * 100)}% · ${formatBytes(task.receivedBytes)} / ${formatBytes(task.totalBytes)}`; + } + return task.receivedBytes ? `${formatBytes(task.receivedBytes)} 已下载` : '正在连接…'; + } + + function taskHtml(task) { + const ratio = task.status === 'complete' + ? 1 + : (task.percent == null ? null : Math.max(0, Math.min(1, task.percent))); + const progressClass = ratio == null && ['pending', 'running', 'pausing'].includes(task.status) + ? ' indeterminate' + : ''; + const width = ratio == null ? 0 : Math.round(ratio * 100); + const book = task.bookTitle && task.bookTitle !== task.name + ? `
${escapeHtml(task.bookTitle)}
` + : ''; + let actions = ''; + if (task.status === 'complete') { + actions = `
+ + + +
`; + } else if (task.status === 'running' || task.status === 'pending') { + actions = `
+ + +
`; + } else if (task.status === 'paused') { + actions = `
+ + +
`; + } else if (!isActive(task)) { + actions = `
`; + } + const state = task.status === 'complete' + ? '已完成' + : (task.status === 'failed' + ? '失败' + : (task.status === 'canceled' + ? '已取消' + : (task.status === 'paused' ? '已暂停' : '下载中'))); + return ` +
+
+
${escapeHtml(task.name)}
+ ${state} +
+ ${book} +
+
+
+
+ ${escapeHtml(statusText(task))} + ${actions} +
+
`; + } + + function render() { + if (!button) return; + const active = tasks.filter(isActive).length; + const paused = tasks.filter((task) => task.status === 'paused').length; + const unread = tasks.filter((task) => task.unread).length; + const count = active + paused + unread; + badge.textContent = String(Math.min(99, count)); + badge.classList.toggle('hidden', count === 0); + badge.classList.toggle('has-result', active === 0 && unread > 0); + button.title = active + ? `任务中心,${active} 个下载中` + : (paused + ? `任务中心,${paused} 个已暂停` + : (unread ? `任务中心,${unread} 个新结果` : '任务中心')); + button.setAttribute('aria-label', button.title); + + const complete = tasks.filter((task) => task.status === 'complete').length; + const failed = tasks.filter((task) => task.status === 'failed').length; + summary.textContent = active + ? `${active} 个下载中${paused ? `,${paused} 个已暂停` : ''}${complete ? `,${complete} 个已完成` : ''}` + : (tasks.length + ? `${paused ? `${paused} 个已暂停,` : ''}${complete} 个已完成${failed ? `,${failed} 个失败` : ''}` + : '下载任务会显示在这里'); + list.innerHTML = tasks.length + ? tasks.map(taskHtml).join('') + : '
暂无下载任务
'; + clearButton.classList.toggle('hidden', !tasks.some((task) => !isUnfinished(task))); + } + + function setPanelOpen(open) { + panel.classList.toggle('hidden', !open); + button.setAttribute('aria-expanded', String(open)); + if (open) { + tasks.forEach((task) => { task.unread = false; }); + render(); + } + } + + function pruneHistory() { + let terminal = tasks.filter((task) => !isUnfinished(task)).length; + for (let i = tasks.length - 1; i >= 0 && terminal > MAX_HISTORY; i--) { + if (isUnfinished(tasks[i])) continue; + tasks.splice(i, 1); + terminal--; + } + } + + function updateProgress(task, data, callback) { + if (!['pausing', 'deleting'].includes(task.status)) task.status = 'running'; + task.receivedBytes = Math.max(0, Number(data && data.receivedBytes) || 0); + const total = Number(data && data.totalBytes); + task.totalBytes = Number.isFinite(total) && total > 0 ? total : null; + const ratio = Number(data && data.percent); + task.percent = data && data.percent != null && Number.isFinite(ratio) + ? Math.max(0, Math.min(1, ratio)) + : null; + render(); + if (typeof callback === 'function') { + try { callback(data); } catch (e) { /* 页面内进度异常不影响全局任务 */ } + } + } + + async function run(task, input, onProgress) { + task.status = 'running'; + task.error = ''; + render(); + let result; + try { + result = await window.api.downloads.run( + task.requestId, + input.url, + input.suggestName, + input.entryId, + input.extraHeaders, + input.meta, + (data) => updateProgress(task, data, onProgress) + ); + } catch (error) { + result = { ok: false, error: (error && error.message) || String(error) }; + } + + if (result && result.ok && result.data && result.data.canceled) { + task.status = 'canceled'; + } else if (result && result.ok && result.data && result.data.paused) { + task.status = 'paused'; + task.receivedBytes = Number(result.data.receivedBytes) || task.receivedBytes; + task.totalBytes = Number(result.data.totalBytes) || task.totalBytes; + task.percent = task.totalBytes ? task.receivedBytes / task.totalBytes : task.percent; + task.promise = null; + render(); + return result; + } else if (result && result.ok && result.data && result.data.deleted) { + const index = tasks.indexOf(task); + if (index >= 0) tasks.splice(index, 1); + render(); + return result; + } else if (!result || !result.ok) { + task.status = 'failed'; + task.error = (result && result.error) || '下载失败'; + } else { + task.status = 'complete'; + task.percent = 1; + task.path = result.data.path || ''; + task.receivedBytes = Math.max(task.receivedBytes, Number(result.data.receivedBytes) || 0); + if (window.Library) window.Library.markDirty(); + } + task.key = ''; + task.input = null; + task.promise = null; + task.unread = panel.classList.contains('hidden'); + pruneHistory(); + render(); + return result; + } + + function start(input) { + const key = String(input && input.key || ''); + const existing = key && tasks.find((task) => task.key === key && isUnfinished(task)); + if (existing) { + if (existing.status === 'paused') { + existing.input.onProgress = input && input.onProgress; + existing.promise = run(existing, existing.input, existing.input.onProgress); + } + return existing.promise || Promise.resolve({ ok: true, data: { paused: true } }); + } + + const meta = input && input.meta || {}; + const task = { + id: `download_${Date.now().toString(36)}_${(++seq).toString(36)}`, + requestId: `task_${Date.now().toString(36)}_${seq.toString(36)}`, + key, + name: String(input && input.suggestName || meta.title || '未命名下载'), + bookTitle: String(meta.title || ''), + status: 'pending', + receivedBytes: 0, + totalBytes: null, + percent: null, + error: '', + path: '', + unread: false, + promise: null, + input: { + url: String(input && input.url || ''), + suggestName: String(input && input.suggestName || ''), + entryId: input && input.entryId, + extraHeaders: input && input.extraHeaders, + meta, + onProgress: input && input.onProgress + } + }; + tasks.unshift(task); + pruneHistory(); + render(); + task.promise = run(task, task.input, task.input.onProgress); + return task.promise; + } + + async function taskAction(action, id) { + const index = tasks.findIndex((task) => task.id === id); + if (index < 0) return; + const task = tasks[index]; + if (action === 'open' && task.path) { + const result = await window.api.openPath(task.path); + if (!result.ok) await confirmModal('打开失败', result.error || '无法打开该文件'); + return; + } + if (action === 'reveal' && task.path) { + window.api.showItem(task.path); + return; + } + if (action === 'pause' && ['pending', 'running'].includes(task.status)) { + const previousStatus = task.status; + task.status = 'pausing'; + render(); + const result = await window.api.downloads.pause(task.requestId); + if ((!result || !result.ok || !result.data) && task.status === 'pausing') { + task.status = previousStatus; + render(); + } + return; + } + if (action === 'resume' && task.status === 'paused' && task.input) { + task.promise = run(task, task.input, task.input.onProgress); + return; + } + if (action === 'delete' && isUnfinished(task)) { + const previousStatus = task.status; + task.status = 'deleting'; + render(); + const result = await window.api.downloads.delete(task.requestId); + if ((!result || !result.ok || !result.data) && task.status === 'deleting') { + task.status = previousStatus; + task.error = (result && result.error) || ''; + render(); + return; + } + if (!task.promise) { + tasks.splice(index, 1); + render(); + } + return; + } + if (action === 'remove' && !isUnfinished(task)) { + tasks.splice(index, 1); + render(); + } + } + + function init() { + button = $('taskCenterBtn'); + badge = $('taskCenterBadge'); + panel = $('taskCenterPanel'); + list = $('taskCenterList'); + summary = $('taskCenterSummary'); + clearButton = $('taskCenterClear'); + + button.onclick = () => setPanelOpen(panel.classList.contains('hidden')); + list.onclick = (event) => { + const actionButton = event.target.closest('[data-task-action]'); + const item = event.target.closest('[data-task-id]'); + if (actionButton && item) taskAction(actionButton.dataset.taskAction, item.dataset.taskId); + }; + clearButton.onclick = () => { + for (let i = tasks.length - 1; i >= 0; i--) { + if (!isUnfinished(tasks[i])) tasks.splice(i, 1); + } + render(); + }; + document.addEventListener('pointerdown', (event) => { + if (!panel.classList.contains('hidden') && !event.target.closest('.task-center-wrap')) { + setPanelOpen(false); + } + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !panel.classList.contains('hidden')) setPanelOpen(false); + }); + render(); + } + + window.DownloadCenter = { init, start }; +})(); diff --git a/src/ui/index.html b/src/ui/index.html index 65aff08..161f68d 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -25,6 +25,25 @@
+
+ + +
+ diff --git a/src/ui/style.css b/src/ui/style.css index c367673..d2326a1 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -111,6 +111,150 @@ body { } .win-btn:hover { background: var(--hover-strong); color: var(--text); } .win-close:hover { background: var(--danger); color: #fff; } +.task-center-wrap { position: relative; } +.task-center-btn { position: relative; } +.task-center-badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 15px; + height: 15px; + padding: 0 4px; + border: 1px solid var(--titlebar-start); + border-radius: 8px; + background: var(--accent); + color: var(--active-text); + font-size: 9px; + font-weight: 700; + line-height: 13px; + text-align: center; +} +.task-center-badge.has-result { background: var(--green); color: #07130b; } +.task-center-panel { + position: absolute; + top: 37px; + right: -210px; + z-index: 50; + width: min(390px, calc(100vw - 20px)); + max-height: min(560px, calc(100vh - 58px)); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: 0 18px 46px rgba(0,0,0,0.32); + -webkit-app-region: no-drag; +} +.task-center-head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 15px 12px; + border-bottom: 1px solid var(--line); +} +.task-center-head > div { min-width: 0; flex: 1; } +.task-center-title { color: var(--text); font-size: 14px; font-weight: 700; } +.task-center-summary { + margin-top: 3px; + overflow: hidden; + color: var(--text-dim); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-clear { + flex: none; + padding: 4px; + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 11px; +} +.task-center-clear:hover { color: var(--text); } +.task-center-list { min-height: 76px; overflow-y: auto; padding: 7px; } +.task-center-empty { + padding: 26px 12px; + color: var(--text-dim); + font-size: 12px; + text-align: center; +} +.task-center-item { + padding: 10px; + border-radius: 8px; + border-left: 3px solid transparent; +} +.task-center-item:hover { background: var(--hover-bg); } +.task-center-item.complete { border-left-color: var(--green); } +.task-center-item.paused { border-left-color: var(--amber); } +.task-center-item.failed { border-left-color: var(--danger); } +.task-center-item-head, +.task-center-item-foot { display: flex; align-items: center; gap: 10px; min-width: 0; } +.task-center-name { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--text); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-state { flex: none; color: var(--text-dim); font-size: 10px; } +.task-center-item.complete .task-center-state { color: var(--green); } +.task-center-item.paused .task-center-state { color: var(--amber); } +.task-center-item.failed .task-center-state { color: var(--danger); } +.task-center-book { + margin-top: 2px; + overflow: hidden; + color: var(--text-dim); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-progress { + position: relative; + height: 4px; + margin: 8px 0 6px; + overflow: hidden; + border-radius: 3px; + background: var(--hover-strong); +} +.task-center-progress-fill { + height: 100%; + background: var(--accent); + border-radius: inherit; + transition: width 0.12s linear; +} +.task-center-item.complete .task-center-progress-fill { background: var(--green); } +.task-center-item.paused .task-center-progress-fill { background: var(--amber); } +.task-center-item.failed .task-center-progress-fill { background: var(--danger); } +.task-center-progress.indeterminate .task-center-progress-fill { + width: 32% !important; + animation: dl-progress-slide 1s ease-in-out infinite; +} +.task-center-status { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--text-dim); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-item.failed .task-center-status { color: var(--danger); } +.task-center-actions { display: flex; flex: none; gap: 7px; } +.task-center-actions button { + padding: 0; + background: none; + border: none; + color: var(--accent-bright); + cursor: pointer; + font-size: 10px; +} +.task-center-actions button:hover { text-decoration: underline; } +.task-center-actions button[data-task-action="delete"] { color: var(--danger); } .titlebar-icon { width: 17px; height: 17px; diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js index 7b3ffba..bb57871 100644 --- a/src/ui/views/browse.js +++ b/src/ui/views/browse.js @@ -483,17 +483,17 @@ const Browse = (() => { const lib = await window.api.library.findBySource(sourceId, sourcePostId); const entryId = (lib.ok && lib.data) ? lib.data.id : undefined; - // 传 meta:条目还不在书库时由主进程自动建,避免下载完却找不到文件 + // 元数据与页面内进度回调交给全局任务中心,详情页离开后下载仍由它接管 let res; try { - res = await window.api.downloadFile( + res = await window.DownloadCenter.start({ + key: `${sourceId}:${sourcePostId}:${suggestedName}`, url, - suggestedName, + suggestName: suggestedName, entryId, - undefined, meta, - (data) => { if (progress) updateDownloadProgress(progress, data); } - ); + onProgress: (data) => { if (progress) updateDownloadProgress(progress, data); } + }); } catch (error) { res = { ok: false, error: (error && error.message) || String(error) }; } @@ -503,6 +503,21 @@ const Browse = (() => { btn.textContent = orig; btn.disabled = false; return; } + if (res.ok && res.data && res.data.paused) { + if (progress) { + progress.box.classList.remove('indeterminate'); + progress.label.textContent = '已暂停,可在任务中心继续'; + } + btn.textContent = '继续'; + btn.disabled = false; + return; + } + if (res.ok && res.data && res.data.deleted) { + if (progress) progress.box.remove(); + btn.textContent = orig; + btn.disabled = false; + return; + } if (!res.ok) { if (progress) { progress.box.classList.add('failed');