@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.
- package/common/index.d.ts +9 -1
- package/common/index.d.ts.map +1 -1
- package/common/index.js.map +1 -1
- package/esm/common/index.d.ts +9 -1
- package/esm/common/index.d.ts.map +1 -1
- package/esm/common/index.js.map +1 -1
- package/esm/main/downloader.d.ts +99 -21
- package/esm/main/downloader.d.ts.map +1 -1
- package/esm/main/downloader.js +438 -87
- package/esm/main/downloader.js.map +1 -1
- package/esm/main/index.d.ts.map +1 -1
- package/esm/main/index.js +46 -11
- package/esm/main/index.js.map +1 -1
- package/main/downloader.d.ts +99 -21
- package/main/downloader.d.ts.map +1 -1
- package/main/downloader.js +438 -87
- package/main/downloader.js.map +1 -1
- package/main/index.d.ts.map +1 -1
- package/main/index.js +46 -11
- package/main/index.js.map +1 -1
- package/package.json +5 -5
package/esm/main/downloader.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
78
|
+
error: parsedError.message,
|
|
79
|
+
errorType: parsedError.type
|
|
72
80
|
};
|
|
73
81
|
}
|
|
74
82
|
});
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
85
|
* 下载文件
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
return
|
|
148
|
+
catch (error) {
|
|
149
|
+
// 路径准备失败:可能是权限问题或文件系统错误
|
|
150
|
+
const parsedError = this._parseError(error, url);
|
|
151
|
+
return this._createErrorResult(`准备输出路径失败: ${parsedError.message}`, parsedError.type);
|
|
116
152
|
}
|
|
117
|
-
|
|
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
|
-
//
|
|
158
|
+
// 验证下载目录是否可访问,如果不可用则使用系统下载目录作为备用
|
|
159
|
+
if (!this._isDirectoryAccessible(downloadDir)) {
|
|
160
|
+
try {
|
|
161
|
+
downloadDir = 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 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(
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
|
282
|
+
options.onError(errorObj);
|
|
197
283
|
}
|
|
198
|
-
reject
|
|
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
|
-
|
|
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
|
-
*
|
|
300
|
-
*
|
|
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 = app.getPath('downloads');
|
|
458
|
+
// 验证下载目录是否可用,如果可用则使用,否则设为 null
|
|
459
|
+
this._lastSelectedDir = this._isDirectoryAccessible(downloadsPath) ? downloadsPath : null;
|
|
460
|
+
}
|
|
317
461
|
defaultDir = this._lastSelectedDir || 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
|
-
|
|
496
|
+
// 验证选择的目录是否可访问,只有可访问时才保存
|
|
497
|
+
if (this._isDirectoryAccessible(selectedDir)) {
|
|
498
|
+
this._lastSelectedDir = selectedDir;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
// 如果选择的目录不可访问,尝试使用下载目录
|
|
502
|
+
const downloadsPath = 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
|
-
*
|
|
400
|
-
*
|
|
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
|
-
|
|
569
|
+
// 文件保存失败:可能是权限问题、磁盘空间不足、路径无效等
|
|
570
|
+
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
|
571
|
+
throw new Error(`保存 base64 文件失败 (${filePath}): ${errorMessage}`);
|
|
412
572
|
}
|
|
413
573
|
}
|
|
414
574
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 = 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
|
-
|
|
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
|
/**
|