downloadService.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. 'use strict';
  2. import { app, shell } from 'electron';
  3. import { getDataDir } from 'ee-core/ps';
  4. import { logger } from 'ee-core/log';
  5. import path from 'node:path';
  6. import fs from 'node:fs';
  7. import { randomUUID } from 'node:crypto';
  8. import { pipeline } from 'node:stream/promises';
  9. import { Readable, Transform } from 'node:stream';
  10. import { localSettingsService } from '../setting/localSettings';
  11. /** 本机设置里存下载目录的键名 */
  12. const DOWNLOAD_DIR_KEY = 'downloadDir';
  13. /** 下载清单:放在数据目录,不放进下载目录本身,免得混进用户的文件列表 */
  14. const MANIFEST_NAME = 'downloads.json';
  15. /** 清单最多保留多少条(文件仍在磁盘上,只是不再列出) */
  16. const MAX_ITEMS = 300;
  17. /**
  18. * 下载服务:把渲染进程拿到的文件流写进**本机下载目录**,并维护一份下载清单。
  19. *
  20. * <p>为什么放在主进程:浏览器另存(<a download>)在桌面端会落到系统默认下载目录、也留不下记录;
  21. * 这里统一落盘到可配置的目录,清单供「下载管理器」展示,并且能直接打开文件/所在目录。
  22. */
  23. class DownloadService {
  24. get manifestPath() {
  25. return path.join(getDataDir(), MANIFEST_NAME);
  26. }
  27. /** 生效的下载目录:本机设置优先,未配置用系统「下载」目录 */
  28. getDownloadDir() {
  29. const configured = localSettingsService.get(DOWNLOAD_DIR_KEY);
  30. if (configured) return configured;
  31. try {
  32. return app.getPath('downloads');
  33. } catch {
  34. return path.join(getDataDir(), 'downloads');
  35. }
  36. }
  37. /** 设置下载目录(必须是已存在的绝对目录;传空表示恢复系统默认) */
  38. setDownloadDir(dir) {
  39. const target = typeof dir === 'string' ? dir.trim() : '';
  40. if (target) {
  41. if (!path.isAbsolute(target)) throw new Error('只支持绝对路径');
  42. if (!fs.existsSync(target)) throw new Error(`目录不存在:${target}`);
  43. if (!fs.statSync(target).isDirectory()) throw new Error('请选择目录,而不是文件');
  44. }
  45. localSettingsService.set(DOWNLOAD_DIR_KEY, target);
  46. return this.getDownloadDir();
  47. }
  48. /**
  49. * 启动时清理「僵死」的下载中记录:进程重启后没有下载线程了,
  50. * 这类记录若继续显示"下载中"会一直转不完,统一标成失败说明原因。
  51. */
  52. reconcileOnce() {
  53. if (this.reconciled) return;
  54. this.reconciled = true;
  55. const items = this.readManifest();
  56. const stale = items.filter((item) => item.status === 'downloading');
  57. if (!stale.length) return;
  58. this.writeManifest(
  59. items.map((item) =>
  60. item.status === 'downloading' ? { ...item, status: 'failed', message: '应用重启,下载已中断' } : item,
  61. ),
  62. );
  63. }
  64. readManifest() {
  65. try {
  66. const file = this.manifestPath;
  67. if (!fs.existsSync(file)) return [];
  68. const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
  69. return Array.isArray(parsed?.items) ? parsed.items.filter((item) => item && typeof item.id === 'string') : [];
  70. } catch (e) {
  71. logger.warn('[download] 读取清单失败:', e instanceof Error ? e.message : String(e));
  72. return [];
  73. }
  74. }
  75. list() {
  76. this.reconcileOnce();
  77. return this.readManifest().map((item) => ({
  78. ...item,
  79. exists: !!item.savedPath && fs.existsSync(item.savedPath),
  80. }));
  81. }
  82. writeManifest(items) {
  83. const file = this.manifestPath;
  84. fs.mkdirSync(path.dirname(file), { recursive: true });
  85. fs.writeFileSync(file, `${JSON.stringify({ items: items.slice(0, MAX_ITEMS) }, null, 2)}\n`, 'utf8');
  86. }
  87. /** 文件名只保留纯名字,挡掉路径分隔符与相对路径,避免写出目录之外 */
  88. sanitizeFileName(fileName) {
  89. const base = String(fileName || '').split(/[\\/]/).pop() || '';
  90. const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]/g, '_').trim();
  91. return cleaned || `download-${Date.now()}`;
  92. }
  93. /** 重名时自动加 (1)(2)…,不覆盖用户已有文件 */
  94. resolveUniquePath(dir, fileName) {
  95. const ext = path.extname(fileName);
  96. const stem = fileName.slice(0, fileName.length - ext.length);
  97. let candidate = path.join(dir, fileName);
  98. let index = 0;
  99. while (fs.existsSync(candidate)) {
  100. index += 1;
  101. candidate = path.join(dir, `${stem} (${index})${ext}`);
  102. }
  103. return candidate;
  104. }
  105. /**
  106. * 保存一次下载。
  107. * @param params { fileName: string, mime?: string, data: ArrayBuffer|Uint8Array|string(base64), source?: string }
  108. */
  109. save(params = {}) {
  110. const fileName = this.sanitizeFileName(params?.fileName);
  111. const raw = params?.data;
  112. let buffer;
  113. if (typeof raw === 'string') {
  114. buffer = Buffer.from(raw, 'base64');
  115. } else if (raw instanceof Uint8Array) {
  116. buffer = Buffer.from(raw);
  117. } else if (raw instanceof ArrayBuffer) {
  118. buffer = Buffer.from(new Uint8Array(raw));
  119. } else if (ArrayBuffer.isView(raw)) {
  120. buffer = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
  121. } else {
  122. throw new Error('下载内容为空或格式不支持');
  123. }
  124. if (!buffer.length) throw new Error('下载内容为空');
  125. const dir = this.getDownloadDir();
  126. fs.mkdirSync(dir, { recursive: true });
  127. const target = this.resolveUniquePath(dir, fileName);
  128. fs.writeFileSync(target, buffer);
  129. const item = {
  130. id: randomUUID(),
  131. fileName: path.basename(target),
  132. savedPath: target,
  133. dir,
  134. bytes: buffer.length,
  135. status: 'done',
  136. received: buffer.length,
  137. total: buffer.length,
  138. mime: typeof params?.mime === 'string' ? params.mime : '',
  139. source: typeof params?.source === 'string' ? params.source : '',
  140. at: new Date().toISOString(),
  141. };
  142. this.writeManifest([item, ...this.list()]);
  143. logger.info('[download] 已保存:', target);
  144. return item;
  145. }
  146. /** 进度回调:由 controller 接到 webContents.send 上,把 in-flight 状态推给界面 */
  147. setNotifier(notifier) {
  148. this.notifier = typeof notifier === 'function' ? notifier : null;
  149. }
  150. notify(item) {
  151. try {
  152. this.notifier?.(item);
  153. } catch (e) {
  154. logger.warn('[download] 进度推送失败:', e instanceof Error ? e.message : String(e));
  155. }
  156. }
  157. /** 更新清单里的一条记录(不动其它条目),并把最新状态推给界面 */
  158. patchItem(id, patch) {
  159. const items = this.list();
  160. const index = items.findIndex((item) => item.id === id);
  161. if (index < 0) return null;
  162. const next = { ...items[index], ...patch };
  163. items[index] = next;
  164. this.writeManifest(items);
  165. this.notify(next);
  166. return next;
  167. }
  168. /**
  169. * 后台下载:主进程直接拉取 URL 落盘,边下边报进度(渲染进程不承担大文件内存与阻塞)。
  170. *
  171. * <p>先写 {@code <名字>.part},下完再改名,避免下载中就被当成完整文件用;
  172. * 同名文件自动加 (1)(2),不覆盖已有文件。
  173. *
  174. * @param params { url, fileName?, headers?, source?, mime? }
  175. */
  176. start(params = {}) {
  177. const url = String(params?.url || '').trim();
  178. if (!url) throw new Error('下载地址为空');
  179. if (!this.notifier) logger.warn('[download] 未设置进度回调,界面拿不到进度');
  180. const fileName = this.sanitizeFileName(params?.fileName || '');
  181. const dir = this.getDownloadDir();
  182. fs.mkdirSync(dir, { recursive: true });
  183. const item = {
  184. id: randomUUID(),
  185. fileName,
  186. savedPath: '',
  187. dir,
  188. bytes: 0,
  189. status: 'downloading',
  190. received: 0,
  191. total: 0,
  192. mime: typeof params?.mime === 'string' ? params.mime : '',
  193. source: typeof params?.source === 'string' ? params.source : '',
  194. at: new Date().toISOString(),
  195. };
  196. this.writeManifest([item, ...this.list()]);
  197. this.notify(item);
  198. // 后台跑,不阻塞调用方;失败也只是把这条记录标成 failed
  199. void this.run(item, url, params);
  200. return item;
  201. }
  202. async run(item, url, params) {
  203. const partPath = path.join(item.dir, `${item.fileName}.part`);
  204. let lastNotify = 0;
  205. try {
  206. const response = await fetch(url, { headers: (params?.headers || {}) });
  207. if (!response.ok) {
  208. // 后端报错时响应体是 JSON({code,message}),尽量把原因带出来
  209. let message = `下载失败:HTTP ${response.status}`;
  210. try {
  211. const text = await response.text();
  212. const parsed = JSON.parse(text);
  213. if (parsed?.message) message = parsed.message;
  214. } catch {
  215. // 不是 JSON 就沿用状态码
  216. }
  217. throw new Error(message);
  218. }
  219. const total = Number(response.headers.get('content-length') || 0);
  220. this.patchItem(item.id, { total, mime: response.headers.get('content-type') || item.mime });
  221. if (response.body) {
  222. const counter = new Transform({
  223. transform: (chunk, _encoding, callback) => {
  224. item.received += chunk.length;
  225. const now = Date.now();
  226. if (now - lastNotify > 150) {
  227. lastNotify = now;
  228. this.patchItem(item.id, { received: item.received });
  229. }
  230. callback(null, chunk);
  231. },
  232. });
  233. await pipeline(Readable.fromWeb(response.body), counter, fs.createWriteStream(partPath));
  234. } else {
  235. const buffer = Buffer.from(await response.arrayBuffer());
  236. await fs.promises.writeFile(partPath, buffer);
  237. item.received = buffer.length;
  238. }
  239. const target = this.resolveUniquePath(item.dir, item.fileName);
  240. await fs.promises.rename(partPath, target);
  241. const size = fs.statSync(target).size;
  242. const finished = this.patchItem(item.id, {
  243. status: 'done',
  244. savedPath: target,
  245. fileName: path.basename(target),
  246. bytes: size,
  247. received: size,
  248. total: size,
  249. });
  250. logger.info('[download] 已保存:', target);
  251. return finished;
  252. } catch (e) {
  253. const message = e instanceof Error ? e.message : String(e);
  254. await fs.promises.rm(partPath, { force: true }).catch(() => undefined);
  255. logger.warn('[download] 下载失败:', message);
  256. return this.patchItem(item.id, { status: 'failed', message });
  257. }
  258. }
  259. find(id) {
  260. const item = this.list().find((entry) => entry.id === id);
  261. if (!item) throw new Error('下载记录不存在');
  262. return item;
  263. }
  264. remove(id) {
  265. const items = this.list();
  266. const next = items.filter((item) => item.id !== id);
  267. if (next.length === items.length) throw new Error('下载记录不存在');
  268. this.writeManifest(next);
  269. return { removed: true };
  270. }
  271. clear() {
  272. const count = this.list().length;
  273. this.writeManifest([]);
  274. return { cleared: count };
  275. }
  276. /** 用系统默认程序打开已下载的文件 */
  277. async openFile(id) {
  278. const item = this.find(id);
  279. if (!fs.existsSync(item.savedPath)) {
  280. throw new Error(`文件已被移动或删除:${item.savedPath}`);
  281. }
  282. const message = await shell.openPath(item.savedPath);
  283. if (message) throw new Error(message);
  284. return { path: item.savedPath };
  285. }
  286. /** 在文件管理器里定位到已下载的文件 */
  287. revealFile(id) {
  288. const item = this.find(id);
  289. if (!fs.existsSync(item.savedPath)) {
  290. throw new Error(`文件已被移动或删除:${item.savedPath}`);
  291. }
  292. shell.showItemInFolder(item.savedPath);
  293. return { path: item.savedPath };
  294. }
  295. /** 打开下载目录本身 */
  296. async openDir() {
  297. const dir = this.getDownloadDir();
  298. fs.mkdirSync(dir, { recursive: true });
  299. const message = await shell.openPath(dir);
  300. if (message) throw new Error(message);
  301. return { path: dir };
  302. }
  303. }
  304. export const downloadService = new DownloadService();