| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- 'use strict';
- import { app, shell } from 'electron';
- import { getDataDir } from 'ee-core/ps';
- import { logger } from 'ee-core/log';
- import path from 'node:path';
- import fs from 'node:fs';
- import { randomUUID } from 'node:crypto';
- import { pipeline } from 'node:stream/promises';
- import { Readable, Transform } from 'node:stream';
- import { localSettingsService } from '../setting/localSettings';
- /** 本机设置里存下载目录的键名 */
- const DOWNLOAD_DIR_KEY = 'downloadDir';
- /** 下载清单:放在数据目录,不放进下载目录本身,免得混进用户的文件列表 */
- const MANIFEST_NAME = 'downloads.json';
- /** 清单最多保留多少条(文件仍在磁盘上,只是不再列出) */
- const MAX_ITEMS = 300;
- /**
- * 下载服务:把渲染进程拿到的文件流写进**本机下载目录**,并维护一份下载清单。
- *
- * <p>为什么放在主进程:浏览器另存(<a download>)在桌面端会落到系统默认下载目录、也留不下记录;
- * 这里统一落盘到可配置的目录,清单供「下载管理器」展示,并且能直接打开文件/所在目录。
- */
- class DownloadService {
- get manifestPath() {
- return path.join(getDataDir(), MANIFEST_NAME);
- }
- /** 生效的下载目录:本机设置优先,未配置用系统「下载」目录 */
- getDownloadDir() {
- const configured = localSettingsService.get(DOWNLOAD_DIR_KEY);
- if (configured) return configured;
- try {
- return app.getPath('downloads');
- } catch {
- return path.join(getDataDir(), 'downloads');
- }
- }
- /** 设置下载目录(必须是已存在的绝对目录;传空表示恢复系统默认) */
- setDownloadDir(dir) {
- const target = typeof dir === 'string' ? dir.trim() : '';
- if (target) {
- if (!path.isAbsolute(target)) throw new Error('只支持绝对路径');
- if (!fs.existsSync(target)) throw new Error(`目录不存在:${target}`);
- if (!fs.statSync(target).isDirectory()) throw new Error('请选择目录,而不是文件');
- }
- localSettingsService.set(DOWNLOAD_DIR_KEY, target);
- return this.getDownloadDir();
- }
- /**
- * 启动时清理「僵死」的下载中记录:进程重启后没有下载线程了,
- * 这类记录若继续显示"下载中"会一直转不完,统一标成失败说明原因。
- */
- reconcileOnce() {
- if (this.reconciled) return;
- this.reconciled = true;
- const items = this.readManifest();
- const stale = items.filter((item) => item.status === 'downloading');
- if (!stale.length) return;
- this.writeManifest(
- items.map((item) =>
- item.status === 'downloading' ? { ...item, status: 'failed', message: '应用重启,下载已中断' } : item,
- ),
- );
- }
- readManifest() {
- try {
- const file = this.manifestPath;
- if (!fs.existsSync(file)) return [];
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
- return Array.isArray(parsed?.items) ? parsed.items.filter((item) => item && typeof item.id === 'string') : [];
- } catch (e) {
- logger.warn('[download] 读取清单失败:', e instanceof Error ? e.message : String(e));
- return [];
- }
- }
- list() {
- this.reconcileOnce();
- return this.readManifest().map((item) => ({
- ...item,
- exists: !!item.savedPath && fs.existsSync(item.savedPath),
- }));
- }
- writeManifest(items) {
- const file = this.manifestPath;
- fs.mkdirSync(path.dirname(file), { recursive: true });
- fs.writeFileSync(file, `${JSON.stringify({ items: items.slice(0, MAX_ITEMS) }, null, 2)}\n`, 'utf8');
- }
- /** 文件名只保留纯名字,挡掉路径分隔符与相对路径,避免写出目录之外 */
- sanitizeFileName(fileName) {
- const base = String(fileName || '').split(/[\\/]/).pop() || '';
- const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]/g, '_').trim();
- return cleaned || `download-${Date.now()}`;
- }
- /** 重名时自动加 (1)(2)…,不覆盖用户已有文件 */
- resolveUniquePath(dir, fileName) {
- const ext = path.extname(fileName);
- const stem = fileName.slice(0, fileName.length - ext.length);
- let candidate = path.join(dir, fileName);
- let index = 0;
- while (fs.existsSync(candidate)) {
- index += 1;
- candidate = path.join(dir, `${stem} (${index})${ext}`);
- }
- return candidate;
- }
- /**
- * 保存一次下载。
- * @param params { fileName: string, mime?: string, data: ArrayBuffer|Uint8Array|string(base64), source?: string }
- */
- save(params = {}) {
- const fileName = this.sanitizeFileName(params?.fileName);
- const raw = params?.data;
- let buffer;
- if (typeof raw === 'string') {
- buffer = Buffer.from(raw, 'base64');
- } else if (raw instanceof Uint8Array) {
- buffer = Buffer.from(raw);
- } else if (raw instanceof ArrayBuffer) {
- buffer = Buffer.from(new Uint8Array(raw));
- } else if (ArrayBuffer.isView(raw)) {
- buffer = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
- } else {
- throw new Error('下载内容为空或格式不支持');
- }
- if (!buffer.length) throw new Error('下载内容为空');
- const dir = this.getDownloadDir();
- fs.mkdirSync(dir, { recursive: true });
- const target = this.resolveUniquePath(dir, fileName);
- fs.writeFileSync(target, buffer);
- const item = {
- id: randomUUID(),
- fileName: path.basename(target),
- savedPath: target,
- dir,
- bytes: buffer.length,
- status: 'done',
- received: buffer.length,
- total: buffer.length,
- mime: typeof params?.mime === 'string' ? params.mime : '',
- source: typeof params?.source === 'string' ? params.source : '',
- at: new Date().toISOString(),
- };
- this.writeManifest([item, ...this.list()]);
- logger.info('[download] 已保存:', target);
- return item;
- }
- /** 进度回调:由 controller 接到 webContents.send 上,把 in-flight 状态推给界面 */
- setNotifier(notifier) {
- this.notifier = typeof notifier === 'function' ? notifier : null;
- }
- notify(item) {
- try {
- this.notifier?.(item);
- } catch (e) {
- logger.warn('[download] 进度推送失败:', e instanceof Error ? e.message : String(e));
- }
- }
- /** 更新清单里的一条记录(不动其它条目),并把最新状态推给界面 */
- patchItem(id, patch) {
- const items = this.list();
- const index = items.findIndex((item) => item.id === id);
- if (index < 0) return null;
- const next = { ...items[index], ...patch };
- items[index] = next;
- this.writeManifest(items);
- this.notify(next);
- return next;
- }
- /**
- * 后台下载:主进程直接拉取 URL 落盘,边下边报进度(渲染进程不承担大文件内存与阻塞)。
- *
- * <p>先写 {@code <名字>.part},下完再改名,避免下载中就被当成完整文件用;
- * 同名文件自动加 (1)(2),不覆盖已有文件。
- *
- * @param params { url, fileName?, headers?, source?, mime? }
- */
- start(params = {}) {
- const url = String(params?.url || '').trim();
- if (!url) throw new Error('下载地址为空');
- if (!this.notifier) logger.warn('[download] 未设置进度回调,界面拿不到进度');
- const fileName = this.sanitizeFileName(params?.fileName || '');
- const dir = this.getDownloadDir();
- fs.mkdirSync(dir, { recursive: true });
- const item = {
- id: randomUUID(),
- fileName,
- savedPath: '',
- dir,
- bytes: 0,
- status: 'downloading',
- received: 0,
- total: 0,
- mime: typeof params?.mime === 'string' ? params.mime : '',
- source: typeof params?.source === 'string' ? params.source : '',
- at: new Date().toISOString(),
- };
- this.writeManifest([item, ...this.list()]);
- this.notify(item);
- // 后台跑,不阻塞调用方;失败也只是把这条记录标成 failed
- void this.run(item, url, params);
- return item;
- }
- async run(item, url, params) {
- const partPath = path.join(item.dir, `${item.fileName}.part`);
- let lastNotify = 0;
- try {
- const response = await fetch(url, { headers: (params?.headers || {}) });
- if (!response.ok) {
- // 后端报错时响应体是 JSON({code,message}),尽量把原因带出来
- let message = `下载失败:HTTP ${response.status}`;
- try {
- const text = await response.text();
- const parsed = JSON.parse(text);
- if (parsed?.message) message = parsed.message;
- } catch {
- // 不是 JSON 就沿用状态码
- }
- throw new Error(message);
- }
- const total = Number(response.headers.get('content-length') || 0);
- this.patchItem(item.id, { total, mime: response.headers.get('content-type') || item.mime });
- if (response.body) {
- const counter = new Transform({
- transform: (chunk, _encoding, callback) => {
- item.received += chunk.length;
- const now = Date.now();
- if (now - lastNotify > 150) {
- lastNotify = now;
- this.patchItem(item.id, { received: item.received });
- }
- callback(null, chunk);
- },
- });
- await pipeline(Readable.fromWeb(response.body), counter, fs.createWriteStream(partPath));
- } else {
- const buffer = Buffer.from(await response.arrayBuffer());
- await fs.promises.writeFile(partPath, buffer);
- item.received = buffer.length;
- }
- const target = this.resolveUniquePath(item.dir, item.fileName);
- await fs.promises.rename(partPath, target);
- const size = fs.statSync(target).size;
- const finished = this.patchItem(item.id, {
- status: 'done',
- savedPath: target,
- fileName: path.basename(target),
- bytes: size,
- received: size,
- total: size,
- });
- logger.info('[download] 已保存:', target);
- return finished;
- } catch (e) {
- const message = e instanceof Error ? e.message : String(e);
- await fs.promises.rm(partPath, { force: true }).catch(() => undefined);
- logger.warn('[download] 下载失败:', message);
- return this.patchItem(item.id, { status: 'failed', message });
- }
- }
- find(id) {
- const item = this.list().find((entry) => entry.id === id);
- if (!item) throw new Error('下载记录不存在');
- return item;
- }
- remove(id) {
- const items = this.list();
- const next = items.filter((item) => item.id !== id);
- if (next.length === items.length) throw new Error('下载记录不存在');
- this.writeManifest(next);
- return { removed: true };
- }
- clear() {
- const count = this.list().length;
- this.writeManifest([]);
- return { cleared: count };
- }
- /** 用系统默认程序打开已下载的文件 */
- async openFile(id) {
- const item = this.find(id);
- if (!fs.existsSync(item.savedPath)) {
- throw new Error(`文件已被移动或删除:${item.savedPath}`);
- }
- const message = await shell.openPath(item.savedPath);
- if (message) throw new Error(message);
- return { path: item.savedPath };
- }
- /** 在文件管理器里定位到已下载的文件 */
- revealFile(id) {
- const item = this.find(id);
- if (!fs.existsSync(item.savedPath)) {
- throw new Error(`文件已被移动或删除:${item.savedPath}`);
- }
- shell.showItemInFolder(item.savedPath);
- return { path: item.savedPath };
- }
- /** 打开下载目录本身 */
- async openDir() {
- const dir = this.getDownloadDir();
- fs.mkdirSync(dir, { recursive: true });
- const message = await shell.openPath(dir);
- if (message) throw new Error(message);
- return { path: dir };
- }
- }
- export const downloadService = new DownloadService();
|