@lynker-desktop/electron-sdk 0.0.9-alpha.81 → 0.0.9-alpha.85

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.
@@ -39,6 +39,10 @@ const MAX_FILENAME_COUNTER = 10000;
39
39
  * 下载器类:提供文件下载功能
40
40
  */
41
41
  class Downloader {
42
+ /**
43
+ * 构造函数:初始化下载器并注册 IPC 处理器
44
+ * 注册 `core:downloader` IPC 处理器,用于从渲染进程接收下载请求
45
+ */
42
46
  constructor() {
43
47
  /** 正在下载的 URL 集合(避免重复下载) */
44
48
  this._downloadingUrls = new Map();
@@ -48,7 +52,7 @@ class Downloader {
48
52
  try {
49
53
  const { id, url } = options;
50
54
  const sender = event.sender;
51
- // 创建进度回调,通过 IPC 发送进度更新
55
+ // 创建进度回调,通过 IPC 发送进度更新到渲染进程
52
56
  const progressCallback = (progress) => {
53
57
  try {
54
58
  if (sender && typeof sender.send === 'function') {
@@ -59,75 +63,127 @@ class Downloader {
59
63
  }
60
64
  }
61
65
  catch (error) {
62
- console.error('发送下载进度失败:', error);
66
+ // 进度回调失败不影响下载,只记录日志
67
+ console.error(`[Downloader] 发送下载进度失败 (ID: ${id}):`, error);
63
68
  }
64
69
  };
65
70
  const result = await this.download(url, options, progressCallback);
66
71
  return result;
67
72
  }
68
73
  catch (error) {
74
+ // IPC 调用异常处理:解析错误并返回明确的错误信息
75
+ const parsedError = this._parseError(error, options?.url);
69
76
  return {
70
77
  success: false,
71
- error: error instanceof Error ? error.message : '未知错误'
78
+ error: parsedError.message,
79
+ errorType: parsedError.type
72
80
  };
73
81
  }
74
82
  });
75
83
  }
76
84
  /**
77
85
  * 下载文件
78
- * @param url 要下载的 URL
79
- * @param options 下载选项
86
+ *
87
+ * 主要流程:
88
+ * 1. 检查是否正在下载(避免重复下载)
89
+ * 2. 确定输出路径(支持弹窗选择或自动解析)
90
+ * 3. 验证并准备输出目录
91
+ * 4. 处理 base64 data URL 或普通 HTTP 下载
92
+ * 5. 使用临时文件下载,完成后重命名为最终文件
93
+ *
94
+ * @param url 要下载的 URL(支持 HTTP/HTTPS 和 base64 data URL)
95
+ * @param options 下载选项(输出路径、重试配置、回调函数等)
80
96
  * @param onProgress 进度回调函数(可选,优先级高于 options.onProgress)
81
- * @returns Promise<DownloadResult> 下载结果
97
+ * @returns Promise<DownloadResult> 下载结果,包含成功/失败状态、文件路径、错误信息等
82
98
  */
83
99
  async download(url, options = {}, onProgress) {
84
- // 生成唯一标识:URL + ID(如果存在)
100
+ // 参数验证:检查 URL 是否有效
101
+ if (!url || typeof url !== 'string' || url.trim().length === 0) {
102
+ return this._createErrorResult('下载 URL 不能为空', 'VALIDATION');
103
+ }
104
+ // 生成唯一标识:URL + ID(如果存在),用于避免重复下载
85
105
  const downloadKey = this._getDownloadKey(url, options.id);
86
- // 检查是否正在下载,避免重复下载
106
+ // 检查是否正在下载,避免重复下载同一资源
87
107
  if (this._downloadingUrls.has(downloadKey)) {
88
- return this._createErrorResult(`资源正在下载中: ${url}${options.id ? ` (ID: ${options.id})` : ''}`);
108
+ return this._createErrorResult(`资源正在下载中,请勿重复下载: ${url}${options.id ? ` (ID: ${options.id})` : ''}`, 'VALIDATION');
89
109
  }
90
110
  // 合并进度回调:优先使用传入的 onProgress,其次使用 options.onProgress
91
111
  const progressCallback = onProgress || options.onProgress;
92
- // 先检查是否是 base64 data URL(用于确定默认扩展名)
112
+ // 先检查是否是 base64 data URL(用于确定默认扩展名和特殊处理)
93
113
  const base64Info = this._isBase64DataUrl(url);
94
114
  const defaultExt = base64Info.isBase64 && base64Info.ext ? base64Info.ext : undefined;
95
- // 确定输出路径
115
+ // 确定输出路径:支持弹窗选择或自动解析
96
116
  let outputPath;
97
117
  try {
98
118
  if (options.showSaveDialog) {
119
+ // 显示保存对话框,让用户选择保存位置
99
120
  const savePath = await this._showSaveDialog(url, options, defaultExt);
100
121
  if (!savePath) {
101
- return this._createErrorResult('用户取消了保存操作');
122
+ return this._createErrorResult('用户取消了保存操作', 'USER_CANCEL');
102
123
  }
103
124
  outputPath = savePath;
104
125
  }
105
126
  else {
127
+ // 自动解析输出路径
106
128
  outputPath = this._resolveOutputPath(url, options, defaultExt);
107
129
  }
130
+ // 验证 outputPath 是否有效
131
+ if (!outputPath || typeof outputPath !== 'string' || outputPath.trim().length === 0) {
132
+ return this._createErrorResult('无法确定输出路径:路径解析结果无效', 'VALIDATION');
133
+ }
108
134
  }
109
135
  catch (error) {
110
- return this._createErrorResult(error instanceof Error ? error.message : '确定输出路径失败');
136
+ // 路径解析失败:可能是文件系统错误或权限问题
137
+ const parsedError = this._parseError(error, url);
138
+ return this._createErrorResult(`确定输出路径失败: ${parsedError.message}`, parsedError.type);
139
+ }
140
+ // 准备输出路径:确保目录存在、检查文件是否已存在
141
+ try {
142
+ const pathCheckResult = await this._prepareOutputPath(outputPath, options);
143
+ if (pathCheckResult) {
144
+ // 文件已存在且不允许覆盖,直接返回已存在的文件信息
145
+ return pathCheckResult;
146
+ }
111
147
  }
112
- // 准备输出路径(确保目录存在、检查文件是否已存在)
113
- const pathCheckResult = await this._prepareOutputPath(outputPath, options);
114
- if (pathCheckResult) {
115
- return pathCheckResult;
148
+ catch (error) {
149
+ // 路径准备失败:可能是权限问题或文件系统错误
150
+ const parsedError = this._parseError(error, url);
151
+ return this._createErrorResult(`准备输出路径失败: ${parsedError.message}`, parsedError.type);
116
152
  }
117
- const downloadDir = path.dirname(outputPath);
153
+ // 提取下载目录和文件名
154
+ let downloadDir = path.dirname(outputPath);
118
155
  let downloadFileName = path.basename(outputPath);
119
- // 再次清理文件名,确保不包含 query 参数和特殊字符
156
+ // 再次清理文件名,确保不包含 query 参数和特殊字符(防止文件系统错误)
120
157
  downloadFileName = this._cleanFileName(downloadFileName);
121
- // 更新 outputPath 使用清理后的文件名
158
+ // 验证下载目录是否可访问,如果不可用则使用系统下载目录作为备用
159
+ if (!this._isDirectoryAccessible(downloadDir)) {
160
+ try {
161
+ downloadDir = electron.app.getPath('downloads');
162
+ // 确保备用目录存在(如果不存在则创建)
163
+ if (!fs.existsSync(downloadDir)) {
164
+ fs.mkdirSync(downloadDir, { recursive: true });
165
+ }
166
+ // 验证备用目录是否可访问
167
+ if (!this._isDirectoryAccessible(downloadDir)) {
168
+ return this._createErrorResult(`无法访问下载目录: ${downloadDir},请检查目录权限`, 'PERMISSION');
169
+ }
170
+ }
171
+ catch (error) {
172
+ const parsedError = this._parseError(error, url);
173
+ return this._createErrorResult(`无法创建或访问备用下载目录: ${parsedError.message}`, parsedError.type);
174
+ }
175
+ }
176
+ // 更新 outputPath 使用清理后的文件名和验证后的目录
122
177
  outputPath = path.join(downloadDir, downloadFileName);
123
- // 如果是 base64 data URL,使用已确定的输出路径进行处理
178
+ // 如果是 base64 data URL,使用已确定的输出路径进行特殊处理(无需网络请求)
124
179
  if (base64Info.isBase64 && base64Info.ext && base64Info.mimeType && base64Info.data) {
125
180
  return await this._handleBase64Download(url, { ext: base64Info.ext, mimeType: base64Info.mimeType, data: base64Info.data }, outputPath, options, progressCallback);
126
181
  }
127
- // 使用临时文件下载,避免下载中断时文件不完整
182
+ // 使用临时文件下载(.cache 后缀),避免下载中断时文件不完整
183
+ // 下载完成后会将临时文件重命名为最终文件
128
184
  const tempFilePath = path.join(downloadDir, downloadFileName + '.cache');
129
185
  return new Promise((resolve, reject) => {
130
- // 创建下载器实例
186
+ // 创建下载器实例(使用 node-downloader-helper)
131
187
  const dl = new nodeDownloaderHelper.DownloaderHelper(url, downloadDir, {
132
188
  fileName: downloadFileName + '.cache',
133
189
  retry: {
@@ -135,16 +191,16 @@ class Downloader {
135
191
  delay: options.retry?.delay ?? DEFAULT_RETRY.delay
136
192
  },
137
193
  timeout: options.timeout ?? DEFAULT_TIMEOUT,
138
- override: true, // 临时文件总是覆盖
194
+ override: true, // 临时文件总是覆盖,避免残留文件
139
195
  httpRequestOptions: {
140
196
  followRedirect: options.httpRequestOptions?.followRedirect ?? true,
141
197
  maxRedirects: options.httpRequestOptions?.maxRedirects ?? MAX_REDIRECTS,
142
198
  headers: options.httpRequestOptions?.headers
143
199
  }
144
200
  });
145
- // 记录正在下载的 URL(使用 URL + ID 作为 key
201
+ // 记录正在下载的 URL(使用 URL + ID 作为 key),用于避免重复下载
146
202
  this._downloadingUrls.set(downloadKey, dl);
147
- // 监听下载进度
203
+ // 监听下载进度事件
148
204
  if (progressCallback) {
149
205
  dl.on('progress', (stats) => {
150
206
  try {
@@ -157,63 +213,101 @@ class Downloader {
157
213
  });
158
214
  }
159
215
  catch (error) {
160
- // 忽略回调中的错误,避免影响下载
161
- console.error('进度回调执行失败:', error);
216
+ // 进度回调失败不影响下载,只记录日志
217
+ console.error(`[Downloader] 进度回调执行失败 (${url}):`, error);
162
218
  }
163
219
  });
164
220
  }
165
- // 监听下载完成
221
+ // 监听下载完成事件
166
222
  dl.on('end', () => {
223
+ // 从下载列表中移除
167
224
  this._downloadingUrls.delete(downloadKey);
168
- // 将临时文件重命名为最终文件
225
+ // 将临时文件重命名为最终文件(原子操作,确保文件完整性)
169
226
  fs.rename(tempFilePath, outputPath, (renameErr) => {
170
227
  if (renameErr) {
171
- const error = new Error(`文件重命名失败 from ${tempFilePath} to ${outputPath}: ${renameErr.message}`);
228
+ // 文件重命名失败:可能是权限问题或文件被占用
229
+ const parsedError = this._parseError(renameErr, url);
230
+ const error = new Error(`文件重命名失败: 无法将临时文件 "${tempFilePath}" 重命名为 "${outputPath}"。${parsedError.message}`);
231
+ // 调用错误回调
172
232
  if (options.onError) {
173
233
  options.onError(error);
174
234
  }
235
+ // reject 应该传入 Error 对象,而不是 DownloadResult
236
+ // 将错误类型附加到错误对象上
237
+ error.errorType = parsedError.type;
175
238
  reject(error);
176
239
  }
177
240
  else {
178
- // 获取文件大小并返回结果
179
- const stats = fs.statSync(outputPath);
180
- const result = this._createSuccessResult(outputPath, stats.size);
181
- if (options.onComplete) {
182
- options.onComplete(outputPath);
241
+ try {
242
+ // 获取文件大小并返回成功结果
243
+ const stats = fs.statSync(outputPath);
244
+ const result = this._createSuccessResult(outputPath, stats.size);
245
+ // 调用完成回调
246
+ if (options.onComplete) {
247
+ options.onComplete(outputPath);
248
+ }
249
+ resolve(result);
250
+ }
251
+ catch (error) {
252
+ // 获取文件信息失败
253
+ const parsedError = this._parseError(error, url);
254
+ const errorObj = new Error(`下载完成但无法获取文件信息: ${parsedError.message}`);
255
+ // 将错误类型附加到错误对象上
256
+ errorObj.errorType = parsedError.type;
257
+ reject(errorObj);
183
258
  }
184
- resolve(result);
185
259
  }
186
260
  });
187
261
  });
188
262
  // 统一的错误处理函数
189
- const handleError = (error) => {
263
+ const handleError = (error, isUserCancel = false) => {
264
+ // 从下载列表中移除
190
265
  this._downloadingUrls.delete(downloadKey);
191
- // 清理临时文件
266
+ // 清理临时文件(忽略删除失败,避免影响错误处理)
192
267
  fs.promises.unlink(tempFilePath).catch(() => {
193
- // 忽略删除失败
268
+ // 临时文件删除失败不影响错误处理流程
194
269
  });
270
+ // 解析错误信息
271
+ const parsedError = this._parseError(error, url);
272
+ const errorType = isUserCancel ? 'USER_CANCEL' : parsedError.type;
273
+ const errorMessage = isUserCancel
274
+ ? `下载已取消: ${url}`
275
+ : parsedError.message;
276
+ // 创建错误对象(包含错误类型信息)
277
+ const errorObj = new Error(errorMessage);
278
+ // 将错误类型附加到错误对象上,供 catch 处理时使用
279
+ errorObj.errorType = errorType;
280
+ // 调用错误回调
195
281
  if (options.onError) {
196
- options.onError(error);
282
+ options.onError(errorObj);
197
283
  }
198
- reject(error);
284
+ // reject 应该传入 Error 对象,而不是 DownloadResult
285
+ reject(errorObj);
199
286
  };
200
- // 监听下载错误
287
+ // 监听下载错误事件(网络错误、服务器错误等)
201
288
  dl.on('error', (err) => {
202
289
  const error = err instanceof Error ? err : new Error(`下载失败: ${url}`);
203
- handleError(error);
290
+ handleError(error, false);
204
291
  });
205
- // 监听下载停止(取消)
292
+ // 监听下载停止事件(用户取消或手动停止)
206
293
  dl.on('stop', () => {
207
294
  const error = new Error(`下载已停止: ${url}`);
208
- handleError(error);
295
+ handleError(error, true);
209
296
  });
210
297
  // 开始下载
211
298
  dl.start().catch((err) => {
299
+ // 启动下载失败:可能是 URL 无效、网络不可达等
212
300
  const error = err instanceof Error ? err : new Error(`启动下载失败: ${url}`);
213
- handleError(error);
301
+ handleError(error, false);
214
302
  });
215
303
  }).catch((error) => {
216
- return this._createErrorResult(error instanceof Error ? error.message : '未知错误');
304
+ // Promise 异常捕获:返回格式化的错误结果
305
+ // 如果错误对象已经包含 errorType,直接使用;否则解析错误
306
+ if (error instanceof Error && error.errorType) {
307
+ return this._createErrorResult(error.message, error.errorType);
308
+ }
309
+ const parsedError = this._parseError(error, url);
310
+ return this._createErrorResult(parsedError.message, parsedError.type);
217
311
  });
218
312
  }
219
313
  /**
@@ -294,10 +388,54 @@ class Downloader {
294
388
  // 从 URL 中提取文件名(_extractFileNameFromUrl 已经包含清理逻辑)
295
389
  return this._extractFileNameFromUrl(url);
296
390
  }
391
+ /**
392
+ * 检查目录是否存在且有访问权限
393
+ *
394
+ * 检查项:
395
+ * 1. 路径是否有效(非空字符串)
396
+ * 2. 目录是否存在
397
+ * 3. 路径是否为目录(而非文件)
398
+ * 4. 是否有读写权限
399
+ *
400
+ * @param dirPath 目录路径
401
+ * @returns 如果目录存在且有读写权限则返回 true,否则返回 false
402
+ */
403
+ _isDirectoryAccessible(dirPath) {
404
+ // 参数验证:检查路径是否有效
405
+ if (!dirPath || typeof dirPath !== 'string') {
406
+ return false;
407
+ }
408
+ try {
409
+ // 检查目录是否存在
410
+ if (!fs.existsSync(dirPath)) {
411
+ return false;
412
+ }
413
+ // 检查是否是目录(而非文件)
414
+ const stats = fs.statSync(dirPath);
415
+ if (!stats.isDirectory()) {
416
+ return false;
417
+ }
418
+ // 检查是否有读写权限(R_OK: 读权限, W_OK: 写权限)
419
+ fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
420
+ return true;
421
+ }
422
+ catch {
423
+ // 如果任何检查失败(不存在、不是目录、无权限等),返回 false
424
+ return false;
425
+ }
426
+ }
297
427
  /**
298
428
  * 显示保存对话框
299
- * @param url 下载 URL
300
- * @param options 下载选项
429
+ *
430
+ * 功能说明:
431
+ * 1. 确定默认保存目录(优先级:defaultPath > outputDir > _lastSelectedDir > 系统下载目录)
432
+ * 2. 验证并更新 _lastSelectedDir(如果不可访问则重置)
433
+ * 3. 生成唯一文件名(如果文件已存在,添加数字后缀)
434
+ * 4. 显示 Electron 保存对话框
435
+ * 5. 记录用户选择的目录(用于下次默认使用)
436
+ *
437
+ * @param url 下载 URL(用于提取默认文件名)
438
+ * @param options 下载选项(包含 defaultPath、outputDir 等)
301
439
  * @param defaultExt 默认扩展名(可选,用于 base64 URL)
302
440
  * @returns Promise<string | null> 用户选择的保存路径,如果取消则返回 null
303
441
  */
@@ -314,6 +452,12 @@ class Downloader {
314
452
  }
315
453
  else {
316
454
  // 如果没有指定目录,使用上一次选择的文件夹,如果没有则使用下载目录
455
+ // 检查 _lastSelectedDir 是否可用,如果不可用则重置为下载目录
456
+ if (this._lastSelectedDir && !this._isDirectoryAccessible(this._lastSelectedDir)) {
457
+ const downloadsPath = electron.app.getPath('downloads');
458
+ // 验证下载目录是否可用,如果可用则使用,否则设为 null
459
+ this._lastSelectedDir = this._isDirectoryAccessible(downloadsPath) ? downloadsPath : null;
460
+ }
317
461
  defaultDir = this._lastSelectedDir || electron.app.getPath('downloads');
318
462
  }
319
463
  // 如果指定了目录,检查文件是否存在并生成唯一文件名
@@ -349,7 +493,15 @@ class Downloader {
349
493
  // 记录用户选择的文件夹路径,用于下次默认使用
350
494
  const selectedDir = path.dirname(result.filePath);
351
495
  if (selectedDir) {
352
- this._lastSelectedDir = selectedDir;
496
+ // 验证选择的目录是否可访问,只有可访问时才保存
497
+ if (this._isDirectoryAccessible(selectedDir)) {
498
+ this._lastSelectedDir = selectedDir;
499
+ }
500
+ else {
501
+ // 如果选择的目录不可访问,尝试使用下载目录
502
+ const downloadsPath = electron.app.getPath('downloads');
503
+ this._lastSelectedDir = this._isDirectoryAccessible(downloadsPath) ? downloadsPath : null;
504
+ }
353
505
  }
354
506
  return result.filePath;
355
507
  }
@@ -396,45 +548,90 @@ class Downloader {
396
548
  }
397
549
  /**
398
550
  * 保存 base64 数据到文件
399
- * @param base64Data base64 编码的数据
400
- * @param filePath 目标文件路径
551
+ *
552
+ * 处理流程:
553
+ * 1. 将 base64 字符串解码为 Buffer
554
+ * 2. 将 Buffer 写入目标文件路径
555
+ *
556
+ * @param base64Data base64 编码的数据字符串
557
+ * @param filePath 目标文件路径(完整路径,包含文件名)
558
+ * @throws 如果解码失败或文件写入失败,抛出错误
401
559
  */
402
560
  async _saveBase64ToFile(base64Data, filePath) {
403
561
  try {
404
- // 解码 base64 数据
562
+ // 解码 base64 数据为 Buffer
405
563
  const buffer = Buffer.from(base64Data, 'base64');
406
- // 使用 Promise 版本的 writeFile
564
+ // 使用 Promise 版本的 writeFile 写入文件
407
565
  // Buffer 继承自 Uint8Array,可以直接使用
408
566
  await fs.promises.writeFile(filePath, buffer);
409
567
  }
410
568
  catch (error) {
411
- throw new Error(`保存 base64 文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
569
+ // 文件保存失败:可能是权限问题、磁盘空间不足、路径无效等
570
+ const errorMessage = error instanceof Error ? error.message : '未知错误';
571
+ throw new Error(`保存 base64 文件失败 (${filePath}): ${errorMessage}`);
412
572
  }
413
573
  }
414
574
  /**
415
- * 准备输出路径(确保目录存在、检查文件是否已存在)
416
- * @param outputPath 输出路径
417
- * @param options 下载选项
418
- * @returns 如果文件已存在且不允许覆盖,返回结果;否则返回 null
575
+ * 准备输出路径
576
+ *
577
+ * 主要功能:
578
+ * 1. 确保输出目录存在(不存在则创建)
579
+ * 2. 检查文件是否已存在
580
+ * 3. 如果文件已存在且不允许覆盖,返回已存在的文件信息
581
+ *
582
+ * @param outputPath 输出文件路径
583
+ * @param options 下载选项(包含 override 选项)
584
+ * @returns 如果文件已存在且不允许覆盖,返回已存在的文件信息;否则返回 null
585
+ * @throws 如果目录创建失败或权限不足,抛出错误
419
586
  */
420
587
  async _prepareOutputPath(outputPath, options) {
421
- const downloadDir = path.dirname(outputPath);
422
- // 确保目录存在
588
+ // 参数验证:确保 outputPath 是有效的非空字符串
589
+ if (!outputPath || typeof outputPath !== 'string' || outputPath.trim().length === 0) {
590
+ throw new Error(`输出路径无效: ${outputPath}`);
591
+ }
592
+ // 规范化路径(解析相对路径、处理 .. 等)
593
+ const normalizedPath = path.resolve(outputPath);
594
+ // 验证规范化后的路径是否有效
595
+ if (!normalizedPath || normalizedPath.trim().length === 0) {
596
+ throw new Error(`无法解析输出路径: ${outputPath}`);
597
+ }
598
+ const downloadDir = path.dirname(normalizedPath);
599
+ // 使用规范化后的路径
600
+ outputPath = normalizedPath;
601
+ // 确保目录存在:如果不存在则创建(递归创建父目录)
423
602
  if (!fs.existsSync(downloadDir)) {
424
- fs.mkdirSync(downloadDir, { recursive: true });
603
+ try {
604
+ fs.mkdirSync(downloadDir, { recursive: true });
605
+ }
606
+ catch (error) {
607
+ // 目录创建失败:可能是权限问题或路径无效
608
+ const parsedError = this._parseError(error);
609
+ throw new Error(`无法创建输出目录 "${downloadDir}": ${parsedError.message}`);
610
+ }
425
611
  }
426
- // 如果文件已存在且不允许覆盖,直接返回
612
+ // 验证目录是否可访问
613
+ if (!this._isDirectoryAccessible(downloadDir)) {
614
+ throw new Error(`无法访问输出目录 "${downloadDir}",请检查目录权限`);
615
+ }
616
+ // 如果文件已存在且不允许覆盖,直接返回已存在的文件信息
427
617
  if (fs.existsSync(outputPath) && !(options.override ?? true)) {
428
- const stats = fs.statSync(outputPath);
429
- return this._createSuccessResult(outputPath, stats.size);
618
+ try {
619
+ const stats = fs.statSync(outputPath);
620
+ return this._createSuccessResult(outputPath, stats.size);
621
+ }
622
+ catch (error) {
623
+ // 获取文件信息失败:可能是权限问题
624
+ const parsedError = this._parseError(error);
625
+ throw new Error(`无法访问已存在的文件 "${outputPath}": ${parsedError.message}`);
626
+ }
430
627
  }
431
628
  return null;
432
629
  }
433
630
  /**
434
631
  * 创建成功结果
435
632
  * @param filePath 文件路径
436
- * @param size 文件大小
437
- * @returns DownloadResult
633
+ * @param size 文件大小(字节)
634
+ * @returns DownloadResult 成功结果对象
438
635
  */
439
636
  _createSuccessResult(filePath, size) {
440
637
  return {
@@ -446,31 +643,120 @@ class Downloader {
446
643
  /**
447
644
  * 创建错误结果
448
645
  * @param error 错误信息
449
- * @returns DownloadResult
646
+ * @param errorType 错误类型(可选,用于区分不同类型的错误)
647
+ * @returns DownloadResult 错误结果对象
450
648
  */
451
- _createErrorResult(error) {
649
+ _createErrorResult(error, errorType) {
452
650
  return {
453
651
  success: false,
454
- error
652
+ error,
653
+ errorType: errorType || 'UNKNOWN'
654
+ };
655
+ }
656
+ /**
657
+ * 从错误对象中提取错误类型和详细信息
658
+ * @param error 错误对象
659
+ * @param url 下载的 URL(用于上下文信息)
660
+ * @returns 包含错误类型和详细错误信息的对象
661
+ */
662
+ _parseError(error, url) {
663
+ if (error instanceof Error) {
664
+ const errorMessage = error.message.toLowerCase();
665
+ const errorCode = error.code?.toLowerCase() || '';
666
+ // 网络相关错误
667
+ if (errorMessage.includes('network') ||
668
+ errorMessage.includes('timeout') ||
669
+ errorMessage.includes('econnrefused') ||
670
+ errorMessage.includes('enotfound') ||
671
+ errorMessage.includes('etimedout') ||
672
+ errorCode.includes('timeout') ||
673
+ errorCode.includes('econnrefused') ||
674
+ errorCode.includes('enotfound') ||
675
+ errorCode.includes('etimedout')) {
676
+ return {
677
+ type: 'NETWORK',
678
+ message: `网络错误: ${error.message}${url ? ` (URL: ${url})` : ''}`
679
+ };
680
+ }
681
+ // 文件系统相关错误
682
+ if (errorMessage.includes('enoent') ||
683
+ errorMessage.includes('eacces') ||
684
+ errorMessage.includes('eperm') ||
685
+ errorMessage.includes('file') ||
686
+ errorMessage.includes('directory') ||
687
+ errorMessage.includes('path') ||
688
+ errorCode.includes('enoent') ||
689
+ errorCode.includes('eacces') ||
690
+ errorCode.includes('eperm')) {
691
+ if (errorMessage.includes('permission') || errorMessage.includes('eacces') || errorMessage.includes('eperm')) {
692
+ return {
693
+ type: 'PERMISSION',
694
+ message: `权限错误: ${error.message}${url ? ` (URL: ${url})` : ''}`
695
+ };
696
+ }
697
+ return {
698
+ type: 'FILE_SYSTEM',
699
+ message: `文件系统错误: ${error.message}${url ? ` (URL: ${url})` : ''}`
700
+ };
701
+ }
702
+ // 验证相关错误
703
+ if (errorMessage.includes('invalid') ||
704
+ errorMessage.includes('validation') ||
705
+ errorMessage.includes('格式') ||
706
+ errorMessage.includes('无效')) {
707
+ return {
708
+ type: 'VALIDATION',
709
+ message: `验证错误: ${error.message}${url ? ` (URL: ${url})` : ''}`
710
+ };
711
+ }
712
+ // 用户取消
713
+ if (errorMessage.includes('cancel') || errorMessage.includes('取消')) {
714
+ return {
715
+ type: 'USER_CANCEL',
716
+ message: error.message
717
+ };
718
+ }
719
+ // 其他错误
720
+ return {
721
+ type: 'UNKNOWN',
722
+ message: `未知错误: ${error.message}${url ? ` (URL: ${url})` : ''}`
723
+ };
724
+ }
725
+ // 非 Error 对象
726
+ return {
727
+ type: 'UNKNOWN',
728
+ message: `未知错误: ${String(error)}${url ? ` (URL: ${url})` : ''}`
455
729
  };
456
730
  }
457
731
  /**
458
732
  * 处理 base64 data URL 的下载
733
+ *
734
+ * base64 data URL 格式:data:[<mediatype>][;base64],<data>
735
+ * 例如:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
736
+ *
737
+ * 处理流程:
738
+ * 1. 验证 base64 数据有效性
739
+ * 2. 触发进度回调(模拟 100% 完成)
740
+ * 3. 解码并保存 base64 数据到文件
741
+ * 4. 返回成功结果
742
+ *
459
743
  * @param url base64 data URL
460
- * @param base64Info base64 信息
744
+ * @param base64Info base64 信息(扩展名、MIME 类型、数据)
461
745
  * @param outputPath 已确定的输出路径
462
746
  * @param options 下载选项
463
- * @param progressCallback 进度回调
747
+ * @param progressCallback 进度回调(可选)
464
748
  * @returns Promise<DownloadResult> 下载结果
465
749
  */
466
750
  async _handleBase64Download(url, base64Info, outputPath, options, progressCallback) {
751
+ // 验证 base64 数据有效性
467
752
  if (!base64Info.data || !base64Info.ext) {
468
- return this._createErrorResult(`无效的 base64 data URL: ${url}`);
753
+ return this._createErrorResult(`无效的 base64 data URL: 缺少数据或扩展名 (URL: ${url.substring(0, 50)}...)`, 'VALIDATION');
469
754
  }
470
755
  try {
756
+ // 计算数据长度(用于进度回调)
757
+ const dataLength = Buffer.from(base64Info.data, 'base64').length;
471
758
  // 模拟进度回调(base64 数据通常很小,立即完成)
472
759
  if (progressCallback) {
473
- const dataLength = Buffer.from(base64Info.data, 'base64').length;
474
760
  progressCallback({
475
761
  url,
476
762
  downloaded: dataLength,
@@ -481,27 +767,42 @@ class Downloader {
481
767
  }
482
768
  // 保存 base64 数据到文件
483
769
  await this._saveBase64ToFile(base64Info.data, outputPath);
484
- // 获取文件大小
770
+ // 获取文件大小并返回成功结果
485
771
  const stats = fs.statSync(outputPath);
486
772
  const result = this._createSuccessResult(outputPath, stats.size);
773
+ // 调用完成回调
487
774
  if (options.onComplete) {
488
775
  options.onComplete(outputPath);
489
776
  }
490
777
  return result;
491
778
  }
492
779
  catch (error) {
493
- const err = error instanceof Error ? error : new Error('保存 base64 文件失败');
780
+ // base64 文件保存失败:可能是文件系统错误或权限问题
781
+ const parsedError = this._parseError(error, url);
782
+ const err = error instanceof Error ? error : new Error(`保存 base64 文件失败: ${parsedError.message}`);
783
+ // 调用错误回调
494
784
  if (options.onError) {
495
785
  options.onError(err);
496
786
  }
497
- return this._createErrorResult(err.message);
787
+ return this._createErrorResult(`保存 base64 文件失败: ${parsedError.message}`, parsedError.type);
498
788
  }
499
789
  }
500
790
  /**
501
791
  * 生成唯一的文件名(如果文件已存在,添加数字后缀)
792
+ *
793
+ * 命名规则:
794
+ * - 如果文件不存在,返回原始文件名
795
+ * - 如果文件已存在,添加数字后缀:filename(1).ext, filename(2).ext, ...
796
+ * - 最多尝试 MAX_FILENAME_COUNTER 次,防止无限循环
797
+ *
798
+ * 示例:
799
+ * - "test.pdf" -> "test.pdf" (如果不存在)
800
+ * - "test.pdf" -> "test(1).pdf" (如果 test.pdf 已存在)
801
+ * - "test(1).pdf" -> "test(2).pdf" (如果 test(1).pdf 也已存在)
802
+ *
502
803
  * @param dir 目录路径
503
- * @param fileName 原始文件名
504
- * @returns 唯一的文件名
804
+ * @param fileName 原始文件名(包含扩展名)
805
+ * @returns 唯一的文件名(如果原文件不存在则返回原文件名,否则返回带数字后缀的文件名)
505
806
  */
506
807
  _generateUniqueFileName(dir, fileName) {
507
808
  // 分离文件名和扩展名
@@ -638,21 +939,71 @@ class Downloader {
638
939
  * @param options 下载选项
639
940
  * @param defaultExt 默认扩展名(可选,用于 base64 URL)
640
941
  * @returns 输出文件路径
942
+ * @throws 如果无法解析有效路径,抛出错误
641
943
  */
642
944
  _resolveOutputPath(url, options, defaultExt) {
643
945
  // 如果提供了完整路径,直接使用(但需要清理文件名部分)
644
946
  if (options.outputPath) {
645
- const dir = path.dirname(options.outputPath);
646
- const fileName = this._cleanFileName(path.basename(options.outputPath));
647
- return path.resolve(path.join(dir, fileName));
947
+ // 验证 outputPath 是否有效
948
+ if (typeof options.outputPath !== 'string' || options.outputPath.trim().length === 0) {
949
+ throw new Error(`无效的输出路径: ${options.outputPath}`);
950
+ }
951
+ try {
952
+ const dir = path.dirname(options.outputPath);
953
+ const fileName = this._cleanFileName(path.basename(options.outputPath));
954
+ // 验证文件名是否有效
955
+ if (!fileName || fileName.trim().length === 0) {
956
+ throw new Error(`无法从路径中提取有效文件名: ${options.outputPath}`);
957
+ }
958
+ const resolvedPath = path.resolve(path.join(dir, fileName));
959
+ // 验证解析后的路径是否有效
960
+ if (!resolvedPath || resolvedPath.trim().length === 0) {
961
+ throw new Error(`路径解析失败: ${options.outputPath}`);
962
+ }
963
+ return resolvedPath;
964
+ }
965
+ catch (error) {
966
+ if (error instanceof Error) {
967
+ throw error;
968
+ }
969
+ throw new Error(`解析输出路径失败: ${String(error)}`);
970
+ }
648
971
  }
649
972
  // 确定输出目录:优先使用指定的目录,否则使用 Electron 的下载目录
650
- const outputDir = options.outputDir
651
- ? path.resolve(options.outputDir)
652
- : electron.app.getPath('downloads');
973
+ let outputDir;
974
+ try {
975
+ if (options.outputDir) {
976
+ if (typeof options.outputDir !== 'string' || options.outputDir.trim().length === 0) {
977
+ throw new Error(`无效的输出目录: ${options.outputDir}`);
978
+ }
979
+ outputDir = path.resolve(options.outputDir);
980
+ }
981
+ else {
982
+ outputDir = electron.app.getPath('downloads');
983
+ }
984
+ // 验证输出目录是否有效
985
+ if (!outputDir || outputDir.trim().length === 0) {
986
+ throw new Error('无法获取有效的输出目录');
987
+ }
988
+ }
989
+ catch (error) {
990
+ if (error instanceof Error) {
991
+ throw error;
992
+ }
993
+ throw new Error(`确定输出目录失败: ${String(error)}`);
994
+ }
653
995
  // 使用统一的文件名获取方法
654
996
  const fileName = this._getFileName(url, options, defaultExt);
655
- return path.join(outputDir, fileName);
997
+ // 验证文件名是否有效
998
+ if (!fileName || fileName.trim().length === 0) {
999
+ throw new Error('无法获取有效的文件名');
1000
+ }
1001
+ const finalPath = path.join(outputDir, fileName);
1002
+ // 验证最终路径是否有效
1003
+ if (!finalPath || finalPath.trim().length === 0) {
1004
+ throw new Error('无法构建有效的输出路径');
1005
+ }
1006
+ return finalPath;
656
1007
  }
657
1008
  }
658
1009
  /**