@lynker-desktop/electron-sdk 0.0.9-alpha.87 → 0.0.9-alpha.89
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/esm/main/downloader.d.ts +17 -14
- package/esm/main/downloader.d.ts.map +1 -1
- package/esm/main/downloader.js +421 -185
- package/esm/main/downloader.js.map +1 -1
- package/main/downloader.d.ts +17 -14
- package/main/downloader.d.ts.map +1 -1
- package/main/downloader.js +421 -185
- package/main/downloader.js.map +1 -1
- package/package.json +5 -5
package/main/downloader.js
CHANGED
|
@@ -48,6 +48,12 @@ class Downloader {
|
|
|
48
48
|
this._downloadingUrls = new Map();
|
|
49
49
|
/** 上一次选择的保存文件夹路径 */
|
|
50
50
|
this._lastSelectedDir = null;
|
|
51
|
+
/** 缓存的下载目录路径 */
|
|
52
|
+
this._cachedDownloadsPath = null;
|
|
53
|
+
/** 目录可访问性缓存(避免重复检查) */
|
|
54
|
+
this._directoryAccessCache = new Map();
|
|
55
|
+
/** 目录缓存有效期(毫秒) */
|
|
56
|
+
this._cacheTTL = 60000; // 60秒
|
|
51
57
|
electron.ipcMain.handle(`core:downloader`, async (event, options) => {
|
|
52
58
|
try {
|
|
53
59
|
const { id, url } = options;
|
|
@@ -109,27 +115,93 @@ class Downloader {
|
|
|
109
115
|
}
|
|
110
116
|
// 合并进度回调:优先使用传入的 onProgress,其次使用 options.onProgress
|
|
111
117
|
const progressCallback = onProgress || options.onProgress;
|
|
118
|
+
// 检查是否是 macOS App Store 环境(process.mas)
|
|
119
|
+
// 在 MAS 环境下,强制显示保存对话框,且不使用临时文件
|
|
120
|
+
const isMas = process.mas === true;
|
|
121
|
+
if (isMas) {
|
|
122
|
+
options.showSaveDialog = true;
|
|
123
|
+
}
|
|
112
124
|
// 先检查是否是 base64 data URL(用于确定默认扩展名和特殊处理)
|
|
113
125
|
const base64Info = this._isBase64DataUrl(url);
|
|
114
126
|
const defaultExt = base64Info.isBase64 && base64Info.ext ? base64Info.ext : undefined;
|
|
115
127
|
// 确定输出路径:支持弹窗选择或自动解析
|
|
116
128
|
let outputPath;
|
|
129
|
+
let downloadDir;
|
|
130
|
+
let downloadFileName;
|
|
117
131
|
try {
|
|
118
132
|
if (options.showSaveDialog) {
|
|
119
133
|
// 显示保存对话框,让用户选择保存位置
|
|
120
|
-
|
|
134
|
+
// 在 MAS 环境下,传递 isMas 标志以跳过权限检查
|
|
135
|
+
const savePath = await this._showSaveDialog(url, options, defaultExt, isMas);
|
|
121
136
|
if (!savePath) {
|
|
122
137
|
return this._createErrorResult('用户取消了保存操作', 'USER_CANCEL');
|
|
123
138
|
}
|
|
124
139
|
outputPath = savePath;
|
|
140
|
+
// 在 MAS 环境下,直接使用用户选择的路径,跳过多余的验证
|
|
141
|
+
if (isMas) {
|
|
142
|
+
// 直接提取目录和文件名,不进行额外的验证和清理
|
|
143
|
+
// 信任系统返回的有效路径,避免权限问题
|
|
144
|
+
downloadDir = path.dirname(outputPath);
|
|
145
|
+
downloadFileName = path.basename(outputPath);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// 非 MAS 环境,进行正常的路径验证和处理
|
|
149
|
+
// 提取下载目录和文件名
|
|
150
|
+
downloadDir = path.dirname(outputPath);
|
|
151
|
+
downloadFileName = path.basename(outputPath);
|
|
152
|
+
// 清理文件名,确保不包含 query 参数和特殊字符
|
|
153
|
+
downloadFileName = this._cleanFileName(downloadFileName);
|
|
154
|
+
// 更新 outputPath 使用清理后的文件名
|
|
155
|
+
outputPath = path.join(downloadDir, downloadFileName);
|
|
156
|
+
}
|
|
125
157
|
}
|
|
126
158
|
else {
|
|
127
159
|
// 自动解析输出路径
|
|
128
160
|
outputPath = this._resolveOutputPath(url, options, defaultExt);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
161
|
+
// 验证 outputPath 是否有效
|
|
162
|
+
if (!outputPath || typeof outputPath !== 'string' || outputPath.trim().length === 0) {
|
|
163
|
+
return this._createErrorResult('无法确定输出路径:路径解析结果无效', 'VALIDATION');
|
|
164
|
+
}
|
|
165
|
+
// 准备输出路径:确保目录存在、检查文件是否已存在
|
|
166
|
+
try {
|
|
167
|
+
const pathCheckResult = await this._prepareOutputPath(outputPath, options);
|
|
168
|
+
if (pathCheckResult) {
|
|
169
|
+
// 文件已存在且不允许覆盖,直接返回已存在的文件信息
|
|
170
|
+
return pathCheckResult;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
// 路径准备失败:可能是权限问题或文件系统错误
|
|
175
|
+
const parsedError = this._parseError(error, url);
|
|
176
|
+
return this._createErrorResult(`准备输出路径失败: ${parsedError.message}`, parsedError.type);
|
|
177
|
+
}
|
|
178
|
+
// 提取下载目录和文件名
|
|
179
|
+
downloadDir = path.dirname(outputPath);
|
|
180
|
+
downloadFileName = path.basename(outputPath);
|
|
181
|
+
// 再次清理文件名,确保不包含 query 参数和特殊字符(防止文件系统错误)
|
|
182
|
+
downloadFileName = this._cleanFileName(downloadFileName);
|
|
183
|
+
// 验证下载目录是否可访问,如果不可用则使用系统下载目录作为备用
|
|
184
|
+
if (!this._isDirectoryAccessible(downloadDir)) {
|
|
185
|
+
try {
|
|
186
|
+
downloadDir = this._getDownloadsPath();
|
|
187
|
+
// 确保备用目录存在(如果不存在则创建)
|
|
188
|
+
if (!fs.existsSync(downloadDir)) {
|
|
189
|
+
fs.mkdirSync(downloadDir, { recursive: true });
|
|
190
|
+
// 创建后清除缓存,强制重新检查
|
|
191
|
+
this._directoryAccessCache.delete(downloadDir);
|
|
192
|
+
}
|
|
193
|
+
// 验证备用目录是否可访问
|
|
194
|
+
if (!this._isDirectoryAccessible(downloadDir)) {
|
|
195
|
+
return this._createErrorResult(`无法访问下载目录: ${downloadDir},请检查目录权限`, 'PERMISSION');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const parsedError = this._parseError(error, url);
|
|
200
|
+
return this._createErrorResult(`无法创建或访问备用下载目录: ${parsedError.message}`, parsedError.type);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// 更新 outputPath 使用清理后的文件名和验证后的目录
|
|
204
|
+
outputPath = path.join(downloadDir, downloadFileName);
|
|
133
205
|
}
|
|
134
206
|
}
|
|
135
207
|
catch (error) {
|
|
@@ -137,55 +209,24 @@ class Downloader {
|
|
|
137
209
|
const parsedError = this._parseError(error, url);
|
|
138
210
|
return this._createErrorResult(`确定输出路径失败: ${parsedError.message}`, parsedError.type);
|
|
139
211
|
}
|
|
140
|
-
// 准备输出路径:确保目录存在、检查文件是否已存在
|
|
141
|
-
try {
|
|
142
|
-
const pathCheckResult = await this._prepareOutputPath(outputPath, options);
|
|
143
|
-
if (pathCheckResult) {
|
|
144
|
-
// 文件已存在且不允许覆盖,直接返回已存在的文件信息
|
|
145
|
-
return pathCheckResult;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
// 路径准备失败:可能是权限问题或文件系统错误
|
|
150
|
-
const parsedError = this._parseError(error, url);
|
|
151
|
-
return this._createErrorResult(`准备输出路径失败: ${parsedError.message}`, parsedError.type);
|
|
152
|
-
}
|
|
153
|
-
// 提取下载目录和文件名
|
|
154
|
-
let downloadDir = path.dirname(outputPath);
|
|
155
|
-
let downloadFileName = path.basename(outputPath);
|
|
156
|
-
// 再次清理文件名,确保不包含 query 参数和特殊字符(防止文件系统错误)
|
|
157
|
-
downloadFileName = this._cleanFileName(downloadFileName);
|
|
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 使用清理后的文件名和验证后的目录
|
|
177
|
-
outputPath = path.join(downloadDir, downloadFileName);
|
|
178
212
|
// 如果是 base64 data URL,使用已确定的输出路径进行特殊处理(无需网络请求)
|
|
179
213
|
if (base64Info.isBase64 && base64Info.ext && base64Info.mimeType && base64Info.data) {
|
|
180
214
|
return await this._handleBase64Download(url, { ext: base64Info.ext, mimeType: base64Info.mimeType, data: base64Info.data }, outputPath, options, progressCallback);
|
|
181
215
|
}
|
|
182
216
|
// 使用临时文件下载(.cache 后缀),避免下载中断时文件不完整
|
|
183
217
|
// 下载完成后会将临时文件重命名为最终文件
|
|
184
|
-
|
|
218
|
+
// 注意:在 macOS App Store 环境下(process.mas === true),不使用临时文件,直接下载到最终路径
|
|
219
|
+
const useTempFile = !isMas;
|
|
220
|
+
const tempFilePath = useTempFile
|
|
221
|
+
? path.join(downloadDir, downloadFileName + '.cache')
|
|
222
|
+
: outputPath;
|
|
223
|
+
const actualFileName = useTempFile
|
|
224
|
+
? downloadFileName + '.cache'
|
|
225
|
+
: downloadFileName;
|
|
185
226
|
return new Promise((resolve, reject) => {
|
|
186
227
|
// 创建下载器实例(使用 node-downloader-helper)
|
|
187
228
|
const dl = new nodeDownloaderHelper.DownloaderHelper(url, downloadDir, {
|
|
188
|
-
fileName:
|
|
229
|
+
fileName: actualFileName,
|
|
189
230
|
retry: {
|
|
190
231
|
maxRetries: options.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
191
232
|
delay: options.retry?.delay ?? DEFAULT_RETRY.delay
|
|
@@ -195,39 +236,175 @@ class Downloader {
|
|
|
195
236
|
httpRequestOptions: {
|
|
196
237
|
followRedirect: options.httpRequestOptions?.followRedirect ?? true,
|
|
197
238
|
maxRedirects: options.httpRequestOptions?.maxRedirects ?? MAX_REDIRECTS,
|
|
198
|
-
headers: options.httpRequestOptions?.headers
|
|
239
|
+
headers: options.httpRequestOptions?.headers,
|
|
240
|
+
// 设置 socket 超时时间(30秒),用于更快检测网络中断
|
|
241
|
+
// 如果 socket 在 30 秒内没有数据传输,会触发超时
|
|
242
|
+
timeout: 30000
|
|
199
243
|
}
|
|
200
244
|
});
|
|
201
245
|
// 记录正在下载的 URL(使用 URL + ID 作为 key),用于避免重复下载
|
|
202
246
|
this._downloadingUrls.set(downloadKey, dl);
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
247
|
+
// 进度停滞检测:用于检测网络中断
|
|
248
|
+
let lastProgressTime = Date.now();
|
|
249
|
+
let lastDownloaded = 0;
|
|
250
|
+
let progressStallTimer = null;
|
|
251
|
+
const PROGRESS_STALL_TIMEOUT = 60000; // 60秒没有进度更新则认为网络中断
|
|
252
|
+
// 进度回调节流:避免频繁触发回调(每100ms最多触发一次)
|
|
253
|
+
let lastProgressCallbackTime = 0;
|
|
254
|
+
const PROGRESS_THROTTLE_MS = 100;
|
|
255
|
+
let pendingProgress = null;
|
|
256
|
+
let progressThrottleTimer = null;
|
|
257
|
+
// 统一的错误处理函数(需要在 resetStallTimer 之前定义)
|
|
258
|
+
const handleError = (error, isUserCancel = false) => {
|
|
259
|
+
// 清理所有定时器
|
|
260
|
+
if (progressStallTimer) {
|
|
261
|
+
clearTimeout(progressStallTimer);
|
|
262
|
+
progressStallTimer = null;
|
|
263
|
+
}
|
|
264
|
+
if (progressThrottleTimer) {
|
|
265
|
+
clearTimeout(progressThrottleTimer);
|
|
266
|
+
progressThrottleTimer = null;
|
|
267
|
+
}
|
|
268
|
+
// 执行最后一次进度回调(如果有待发送的数据)
|
|
269
|
+
if (pendingProgress) {
|
|
270
|
+
flushProgressCallback();
|
|
271
|
+
}
|
|
272
|
+
// 从下载列表中移除
|
|
273
|
+
this._downloadingUrls.delete(downloadKey);
|
|
274
|
+
// 清理临时文件(忽略删除失败,避免影响错误处理)
|
|
275
|
+
// 注意:在 macOS App Store 环境下不使用临时文件,无需清理
|
|
276
|
+
if (useTempFile) {
|
|
277
|
+
fs.promises.unlink(tempFilePath).catch(() => {
|
|
278
|
+
// 临时文件删除失败不影响错误处理流程
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// 解析错误信息
|
|
282
|
+
const parsedError = this._parseError(error, url);
|
|
283
|
+
const errorType = isUserCancel ? 'USER_CANCEL' : parsedError.type;
|
|
284
|
+
const errorMessage = isUserCancel
|
|
285
|
+
? `下载已取消: ${url}`
|
|
286
|
+
: parsedError.message;
|
|
287
|
+
// 创建错误对象(包含错误类型信息)
|
|
288
|
+
const errorObj = new Error(errorMessage);
|
|
289
|
+
// 将错误类型附加到错误对象上,供 catch 处理时使用
|
|
290
|
+
errorObj.errorType = errorType;
|
|
291
|
+
// 调用错误回调
|
|
292
|
+
if (options.onError) {
|
|
293
|
+
options.onError(errorObj);
|
|
294
|
+
}
|
|
295
|
+
// reject 应该传入 Error 对象,而不是 DownloadResult
|
|
296
|
+
reject(errorObj);
|
|
297
|
+
};
|
|
298
|
+
// 清理进度停滞检测定时器
|
|
299
|
+
const clearStallTimer = () => {
|
|
300
|
+
if (progressStallTimer) {
|
|
301
|
+
clearTimeout(progressStallTimer);
|
|
302
|
+
progressStallTimer = null;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
// 重置进度停滞检测定时器
|
|
306
|
+
const resetStallTimer = () => {
|
|
307
|
+
clearStallTimer();
|
|
308
|
+
progressStallTimer = setTimeout(() => {
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
const timeSinceLastProgress = now - lastProgressTime;
|
|
311
|
+
// 如果超过停滞时间阈值,认为网络已中断
|
|
312
|
+
if (timeSinceLastProgress >= PROGRESS_STALL_TIMEOUT) {
|
|
313
|
+
const error = new Error(`网络连接中断:下载进度在 ${Math.round(timeSinceLastProgress / 1000)} 秒内没有更新`);
|
|
314
|
+
error.code = 'ENETWORKSTALL';
|
|
315
|
+
handleError(error, false);
|
|
316
|
+
}
|
|
317
|
+
}, PROGRESS_STALL_TIMEOUT);
|
|
318
|
+
};
|
|
319
|
+
// 节流进度回调执行
|
|
320
|
+
const flushProgressCallback = () => {
|
|
321
|
+
if (pendingProgress && progressCallback) {
|
|
206
322
|
try {
|
|
207
|
-
progressCallback(
|
|
208
|
-
|
|
209
|
-
downloaded: stats.downloaded || 0,
|
|
210
|
-
total: stats.total || 0,
|
|
211
|
-
percentage: stats.progress !== undefined ? Math.round(stats.progress) : -1,
|
|
212
|
-
speed: stats.speed || 0
|
|
213
|
-
});
|
|
323
|
+
progressCallback(pendingProgress);
|
|
324
|
+
pendingProgress = null;
|
|
214
325
|
}
|
|
215
326
|
catch (error) {
|
|
216
327
|
// 进度回调失败不影响下载,只记录日志
|
|
217
328
|
console.error(`[Downloader] 进度回调执行失败 (${url}):`, error);
|
|
218
329
|
}
|
|
219
|
-
}
|
|
220
|
-
|
|
330
|
+
}
|
|
331
|
+
progressThrottleTimer = null;
|
|
332
|
+
};
|
|
333
|
+
// 监听下载进度事件
|
|
334
|
+
dl.on('progress', (stats) => {
|
|
335
|
+
const currentDownloaded = stats.downloaded || 0;
|
|
336
|
+
const currentTime = Date.now();
|
|
337
|
+
// 如果下载量有增加,更新最后进度时间和下载量,并重置停滞检测定时器
|
|
338
|
+
if (currentDownloaded > lastDownloaded) {
|
|
339
|
+
lastDownloaded = currentDownloaded;
|
|
340
|
+
lastProgressTime = currentTime;
|
|
341
|
+
// 重置停滞检测定时器(因为下载有进展)
|
|
342
|
+
resetStallTimer();
|
|
343
|
+
}
|
|
344
|
+
// 节流进度回调:更新待发送的进度数据
|
|
345
|
+
if (progressCallback) {
|
|
346
|
+
pendingProgress = {
|
|
347
|
+
url,
|
|
348
|
+
downloaded: currentDownloaded,
|
|
349
|
+
total: stats.total || 0,
|
|
350
|
+
percentage: stats.progress !== undefined ? Math.round(stats.progress) : -1,
|
|
351
|
+
speed: stats.speed || 0
|
|
352
|
+
};
|
|
353
|
+
// 如果距离上次回调超过节流时间,立即执行
|
|
354
|
+
const timeSinceLastCallback = currentTime - lastProgressCallbackTime;
|
|
355
|
+
if (timeSinceLastCallback >= PROGRESS_THROTTLE_MS) {
|
|
356
|
+
flushProgressCallback();
|
|
357
|
+
lastProgressCallbackTime = currentTime;
|
|
358
|
+
}
|
|
359
|
+
else if (!progressThrottleTimer) {
|
|
360
|
+
// 否则设置定时器延迟执行
|
|
361
|
+
progressThrottleTimer = setTimeout(() => {
|
|
362
|
+
flushProgressCallback();
|
|
363
|
+
lastProgressCallbackTime = Date.now();
|
|
364
|
+
}, PROGRESS_THROTTLE_MS - timeSinceLastCallback);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
// 启动进度停滞检测
|
|
369
|
+
resetStallTimer();
|
|
221
370
|
// 监听下载完成事件
|
|
222
371
|
dl.on('end', () => {
|
|
372
|
+
clearStallTimer();
|
|
373
|
+
// 清理进度节流定时器
|
|
374
|
+
if (progressThrottleTimer) {
|
|
375
|
+
clearTimeout(progressThrottleTimer);
|
|
376
|
+
progressThrottleTimer = null;
|
|
377
|
+
}
|
|
378
|
+
// 执行最后一次进度回调(确保100%进度被发送)
|
|
379
|
+
if (pendingProgress) {
|
|
380
|
+
flushProgressCallback();
|
|
381
|
+
}
|
|
223
382
|
// 从下载列表中移除
|
|
224
383
|
this._downloadingUrls.delete(downloadKey);
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
384
|
+
// 在 macOS App Store 环境下,不使用临时文件,直接使用最终路径
|
|
385
|
+
if (!useTempFile) {
|
|
386
|
+
// 直接获取文件信息并返回成功结果
|
|
387
|
+
fs.promises.stat(outputPath).then(async (stats) => {
|
|
388
|
+
try {
|
|
389
|
+
const result = this._createSuccessResult(outputPath, stats.size);
|
|
390
|
+
// 调用完成回调
|
|
391
|
+
if (options.onComplete) {
|
|
392
|
+
options.onComplete(outputPath);
|
|
393
|
+
}
|
|
394
|
+
resolve(result);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
// 获取文件信息失败
|
|
398
|
+
const parsedError = this._parseError(error, url);
|
|
399
|
+
const errorObj = new Error(`下载完成但无法获取文件信息: ${parsedError.message}`);
|
|
400
|
+
// 将错误类型附加到错误对象上
|
|
401
|
+
errorObj.errorType = parsedError.type;
|
|
402
|
+
reject(errorObj);
|
|
403
|
+
}
|
|
404
|
+
}).catch((statErr) => {
|
|
405
|
+
// 获取文件信息失败
|
|
406
|
+
const parsedError = this._parseError(statErr, url);
|
|
407
|
+
const error = new Error(`下载完成但无法获取文件信息: ${parsedError.message}`);
|
|
231
408
|
// 调用错误回调
|
|
232
409
|
if (options.onError) {
|
|
233
410
|
options.onError(error);
|
|
@@ -236,11 +413,14 @@ class Downloader {
|
|
|
236
413
|
// 将错误类型附加到错误对象上
|
|
237
414
|
error.errorType = parsedError.type;
|
|
238
415
|
reject(error);
|
|
239
|
-
}
|
|
240
|
-
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
// 将临时文件重命名为最终文件(使用异步操作,避免阻塞)
|
|
420
|
+
fs.promises.rename(tempFilePath, outputPath).then(async () => {
|
|
241
421
|
try {
|
|
242
|
-
//
|
|
243
|
-
const stats = fs.
|
|
422
|
+
// 获取文件大小并返回成功结果(使用异步操作)
|
|
423
|
+
const stats = await fs.promises.stat(outputPath);
|
|
244
424
|
const result = this._createSuccessResult(outputPath, stats.size);
|
|
245
425
|
// 调用完成回调
|
|
246
426
|
if (options.onComplete) {
|
|
@@ -256,37 +436,45 @@ class Downloader {
|
|
|
256
436
|
errorObj.errorType = parsedError.type;
|
|
257
437
|
reject(errorObj);
|
|
258
438
|
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
// 调用错误回调
|
|
281
|
-
if (options.onError) {
|
|
282
|
-
options.onError(errorObj);
|
|
439
|
+
}).catch((renameErr) => {
|
|
440
|
+
// 文件重命名失败:可能是权限问题或文件被占用
|
|
441
|
+
const parsedError = this._parseError(renameErr, url);
|
|
442
|
+
const error = new Error(`文件重命名失败: 无法将临时文件 "${tempFilePath}" 重命名为 "${outputPath}"。${parsedError.message}`);
|
|
443
|
+
// 调用错误回调
|
|
444
|
+
if (options.onError) {
|
|
445
|
+
options.onError(error);
|
|
446
|
+
}
|
|
447
|
+
// reject 应该传入 Error 对象,而不是 DownloadResult
|
|
448
|
+
// 将错误类型附加到错误对象上
|
|
449
|
+
error.errorType = parsedError.type;
|
|
450
|
+
reject(error);
|
|
451
|
+
});
|
|
283
452
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
453
|
+
});
|
|
454
|
+
// 监听超时事件(socket 超时,通常表示网络中断)
|
|
455
|
+
dl.on('timeout', () => {
|
|
456
|
+
const error = new Error(`网络连接超时:下载在指定时间内没有数据传输`);
|
|
457
|
+
error.code = 'ETIMEDOUT';
|
|
458
|
+
handleError(error, false);
|
|
459
|
+
});
|
|
287
460
|
// 监听下载错误事件(网络错误、服务器错误等)
|
|
288
461
|
dl.on('error', (err) => {
|
|
289
|
-
|
|
462
|
+
// 检查是否是超时错误
|
|
463
|
+
const errorStats = err;
|
|
464
|
+
let error;
|
|
465
|
+
if (errorStats && typeof errorStats === 'object') {
|
|
466
|
+
// node-downloader-helper 的 error 事件传递的是 ErrorStats 对象
|
|
467
|
+
error = new Error(errorStats.message || `下载失败: ${url}`);
|
|
468
|
+
if (errorStats.status) {
|
|
469
|
+
error.status = errorStats.status;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else if (err instanceof Error) {
|
|
473
|
+
error = err;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
error = new Error(`下载失败: ${url}`);
|
|
477
|
+
}
|
|
290
478
|
handleError(error, false);
|
|
291
479
|
});
|
|
292
480
|
// 监听下载停止事件(用户取消或手动停止)
|
|
@@ -389,7 +577,16 @@ class Downloader {
|
|
|
389
577
|
return this._extractFileNameFromUrl(url);
|
|
390
578
|
}
|
|
391
579
|
/**
|
|
392
|
-
*
|
|
580
|
+
* 获取下载目录路径(带缓存)
|
|
581
|
+
*/
|
|
582
|
+
_getDownloadsPath() {
|
|
583
|
+
if (this._cachedDownloadsPath === null) {
|
|
584
|
+
this._cachedDownloadsPath = electron.app.getPath('downloads');
|
|
585
|
+
}
|
|
586
|
+
return this._cachedDownloadsPath;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 检查目录是否存在且有访问权限(带缓存优化)
|
|
393
590
|
*
|
|
394
591
|
* 检查项:
|
|
395
592
|
* 1. 路径是否有效(非空字符串)
|
|
@@ -405,24 +602,38 @@ class Downloader {
|
|
|
405
602
|
if (!dirPath || typeof dirPath !== 'string') {
|
|
406
603
|
return false;
|
|
407
604
|
}
|
|
605
|
+
// 检查缓存
|
|
606
|
+
const cached = this._directoryAccessCache.get(dirPath);
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
if (cached && (now - cached.timestamp) < this._cacheTTL) {
|
|
609
|
+
return cached.accessible;
|
|
610
|
+
}
|
|
611
|
+
let accessible = false;
|
|
408
612
|
try {
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
return false;
|
|
412
|
-
}
|
|
413
|
-
// 检查是否是目录(而非文件)
|
|
613
|
+
// 优化:使用 statSync 同时检查存在性和类型,然后检查权限
|
|
614
|
+
// 这样可以减少文件系统调用次数
|
|
414
615
|
const stats = fs.statSync(dirPath);
|
|
415
|
-
if (
|
|
416
|
-
|
|
616
|
+
if (stats.isDirectory()) {
|
|
617
|
+
// 检查是否有读写权限(R_OK: 读权限, W_OK: 写权限)
|
|
618
|
+
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
|
619
|
+
accessible = true;
|
|
417
620
|
}
|
|
418
|
-
// 检查是否有读写权限(R_OK: 读权限, W_OK: 写权限)
|
|
419
|
-
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
|
420
|
-
return true;
|
|
421
621
|
}
|
|
422
622
|
catch {
|
|
423
623
|
// 如果任何检查失败(不存在、不是目录、无权限等),返回 false
|
|
424
|
-
|
|
624
|
+
accessible = false;
|
|
625
|
+
}
|
|
626
|
+
// 更新缓存
|
|
627
|
+
this._directoryAccessCache.set(dirPath, { accessible, timestamp: now });
|
|
628
|
+
// 清理过期缓存(简单策略:当缓存超过100个条目时清理)
|
|
629
|
+
if (this._directoryAccessCache.size > 100) {
|
|
630
|
+
for (const [key, value] of this._directoryAccessCache.entries()) {
|
|
631
|
+
if ((now - value.timestamp) >= this._cacheTTL) {
|
|
632
|
+
this._directoryAccessCache.delete(key);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
425
635
|
}
|
|
636
|
+
return accessible;
|
|
426
637
|
}
|
|
427
638
|
/**
|
|
428
639
|
* 显示保存对话框
|
|
@@ -437,9 +648,10 @@ class Downloader {
|
|
|
437
648
|
* @param url 下载 URL(用于提取默认文件名)
|
|
438
649
|
* @param options 下载选项(包含 defaultPath、outputDir 等)
|
|
439
650
|
* @param defaultExt 默认扩展名(可选,用于 base64 URL)
|
|
651
|
+
* @param isMas 是否是 macOS App Store 环境(可选,用于跳过权限检查)
|
|
440
652
|
* @returns Promise<string | null> 用户选择的保存路径,如果取消则返回 null
|
|
441
653
|
*/
|
|
442
|
-
async _showSaveDialog(url, options, defaultExt) {
|
|
654
|
+
async _showSaveDialog(url, options, defaultExt, isMas = false) {
|
|
443
655
|
// 获取并清理默认文件名
|
|
444
656
|
let defaultFileName = this._getFileName(url, options, defaultExt);
|
|
445
657
|
// 获取默认目录
|
|
@@ -452,16 +664,23 @@ class Downloader {
|
|
|
452
664
|
}
|
|
453
665
|
else {
|
|
454
666
|
// 如果没有指定目录,使用上一次选择的文件夹,如果没有则使用下载目录
|
|
455
|
-
//
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
667
|
+
// 在 MAS 环境下,跳过权限检查,直接使用缓存的目录或下载目录
|
|
668
|
+
if (isMas) {
|
|
669
|
+
defaultDir = this._lastSelectedDir || this._getDownloadsPath();
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
// 检查 _lastSelectedDir 是否可用,如果不可用则重置为下载目录
|
|
673
|
+
if (this._lastSelectedDir && !this._isDirectoryAccessible(this._lastSelectedDir)) {
|
|
674
|
+
const downloadsPath = this._getDownloadsPath();
|
|
675
|
+
// 验证下载目录是否可用,如果可用则使用,否则设为 null
|
|
676
|
+
this._lastSelectedDir = this._isDirectoryAccessible(downloadsPath) ? downloadsPath : null;
|
|
677
|
+
}
|
|
678
|
+
defaultDir = this._lastSelectedDir || this._getDownloadsPath();
|
|
460
679
|
}
|
|
461
|
-
defaultDir = this._lastSelectedDir || electron.app.getPath('downloads');
|
|
462
680
|
}
|
|
463
681
|
// 如果指定了目录,检查文件是否存在并生成唯一文件名
|
|
464
|
-
|
|
682
|
+
// 在 MAS 环境下,跳过文件系统访问,直接使用默认文件名
|
|
683
|
+
if (defaultDir && !isMas) {
|
|
465
684
|
defaultFileName = this._generateUniqueFileName(defaultDir, defaultFileName);
|
|
466
685
|
}
|
|
467
686
|
// 构建默认路径
|
|
@@ -491,16 +710,23 @@ class Downloader {
|
|
|
491
710
|
return null;
|
|
492
711
|
}
|
|
493
712
|
// 记录用户选择的文件夹路径,用于下次默认使用
|
|
713
|
+
// 在 MAS 环境下,直接保存用户选择的目录,不进行权限检查
|
|
494
714
|
const selectedDir = path.dirname(result.filePath);
|
|
495
715
|
if (selectedDir) {
|
|
496
|
-
|
|
497
|
-
|
|
716
|
+
if (isMas) {
|
|
717
|
+
// MAS 环境下,直接保存用户选择的目录,信任系统返回的路径
|
|
498
718
|
this._lastSelectedDir = selectedDir;
|
|
499
719
|
}
|
|
500
720
|
else {
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
721
|
+
// 验证选择的目录是否可访问,只有可访问时才保存
|
|
722
|
+
if (this._isDirectoryAccessible(selectedDir)) {
|
|
723
|
+
this._lastSelectedDir = selectedDir;
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
// 如果选择的目录不可访问,尝试使用下载目录
|
|
727
|
+
const downloadsPath = this._getDownloadsPath();
|
|
728
|
+
this._lastSelectedDir = this._isDirectoryAccessible(downloadsPath) ? downloadsPath : null;
|
|
729
|
+
}
|
|
504
730
|
}
|
|
505
731
|
}
|
|
506
732
|
return result.filePath;
|
|
@@ -546,31 +772,6 @@ class Downloader {
|
|
|
546
772
|
return { isBase64: false };
|
|
547
773
|
}
|
|
548
774
|
}
|
|
549
|
-
/**
|
|
550
|
-
* 保存 base64 数据到文件
|
|
551
|
-
*
|
|
552
|
-
* 处理流程:
|
|
553
|
-
* 1. 将 base64 字符串解码为 Buffer
|
|
554
|
-
* 2. 将 Buffer 写入目标文件路径
|
|
555
|
-
*
|
|
556
|
-
* @param base64Data base64 编码的数据字符串
|
|
557
|
-
* @param filePath 目标文件路径(完整路径,包含文件名)
|
|
558
|
-
* @throws 如果解码失败或文件写入失败,抛出错误
|
|
559
|
-
*/
|
|
560
|
-
async _saveBase64ToFile(base64Data, filePath) {
|
|
561
|
-
try {
|
|
562
|
-
// 解码 base64 数据为 Buffer
|
|
563
|
-
const buffer = Buffer.from(base64Data, 'base64');
|
|
564
|
-
// 使用 Promise 版本的 writeFile 写入文件
|
|
565
|
-
// Buffer 继承自 Uint8Array,可以直接使用
|
|
566
|
-
await fs.promises.writeFile(filePath, buffer);
|
|
567
|
-
}
|
|
568
|
-
catch (error) {
|
|
569
|
-
// 文件保存失败:可能是权限问题、磁盘空间不足、路径无效等
|
|
570
|
-
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
|
571
|
-
throw new Error(`保存 base64 文件失败 (${filePath}): ${errorMessage}`);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
775
|
/**
|
|
575
776
|
* 准备输出路径
|
|
576
777
|
*
|
|
@@ -654,59 +855,72 @@ class Downloader {
|
|
|
654
855
|
};
|
|
655
856
|
}
|
|
656
857
|
/**
|
|
657
|
-
*
|
|
858
|
+
* 从错误对象中提取错误类型和详细信息(性能优化版本)
|
|
658
859
|
* @param error 错误对象
|
|
659
860
|
* @param url 下载的 URL(用于上下文信息)
|
|
660
861
|
* @returns 包含错误类型和详细错误信息的对象
|
|
661
862
|
*/
|
|
662
863
|
_parseError(error, url) {
|
|
663
864
|
if (error instanceof Error) {
|
|
865
|
+
// 优化:只转换一次为小写,避免重复转换
|
|
664
866
|
const errorMessage = error.message.toLowerCase();
|
|
665
|
-
const errorCode = error.code
|
|
867
|
+
const errorCode = (error.code || '').toLowerCase();
|
|
868
|
+
const urlSuffix = url ? ` (URL: ${url})` : '';
|
|
869
|
+
// 优化:优先检查错误代码(更可靠且更快)
|
|
870
|
+
if (errorCode) {
|
|
871
|
+
if (errorCode === 'enetworkstall' || errorCode === 'etimedout' ||
|
|
872
|
+
errorCode === 'econnrefused' || errorCode === 'enotfound') {
|
|
873
|
+
return {
|
|
874
|
+
type: 'NETWORK',
|
|
875
|
+
message: `网络错误: ${error.message}${urlSuffix}`
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (errorCode === 'eacces' || errorCode === 'eperm') {
|
|
879
|
+
return {
|
|
880
|
+
type: 'PERMISSION',
|
|
881
|
+
message: `权限错误: ${error.message}${urlSuffix}`
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
if (errorCode === 'enoent') {
|
|
885
|
+
return {
|
|
886
|
+
type: 'FILE_SYSTEM',
|
|
887
|
+
message: `文件系统错误: ${error.message}${urlSuffix}`
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// 检查错误消息(作为后备)
|
|
666
892
|
// 网络相关错误
|
|
667
|
-
if (errorMessage.includes('network') ||
|
|
668
|
-
errorMessage.includes('
|
|
669
|
-
errorMessage.includes('
|
|
670
|
-
errorMessage.includes('
|
|
671
|
-
errorMessage.includes('etimedout') ||
|
|
672
|
-
errorCode.includes('timeout') ||
|
|
673
|
-
errorCode.includes('econnrefused') ||
|
|
674
|
-
errorCode.includes('enotfound') ||
|
|
675
|
-
errorCode.includes('etimedout')) {
|
|
893
|
+
if (errorMessage.includes('network') || errorMessage.includes('timeout') ||
|
|
894
|
+
errorMessage.includes('econnrefused') || errorMessage.includes('enotfound') ||
|
|
895
|
+
errorMessage.includes('etimedout') || errorMessage.includes('连接中断') ||
|
|
896
|
+
errorMessage.includes('连接超时')) {
|
|
676
897
|
return {
|
|
677
898
|
type: 'NETWORK',
|
|
678
|
-
message: `网络错误: ${error.message}${
|
|
899
|
+
message: `网络错误: ${error.message}${urlSuffix}`
|
|
679
900
|
};
|
|
680
901
|
}
|
|
681
902
|
// 文件系统相关错误
|
|
682
|
-
if (errorMessage.includes('enoent') ||
|
|
683
|
-
errorMessage.includes('
|
|
684
|
-
errorMessage.includes('
|
|
685
|
-
errorMessage.includes('
|
|
686
|
-
|
|
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')) {
|
|
903
|
+
if (errorMessage.includes('enoent') || errorMessage.includes('eacces') ||
|
|
904
|
+
errorMessage.includes('eperm') || errorMessage.includes('file') ||
|
|
905
|
+
errorMessage.includes('directory') || errorMessage.includes('path')) {
|
|
906
|
+
if (errorMessage.includes('permission') || errorMessage.includes('eacces') ||
|
|
907
|
+
errorMessage.includes('eperm')) {
|
|
692
908
|
return {
|
|
693
909
|
type: 'PERMISSION',
|
|
694
|
-
message: `权限错误: ${error.message}${
|
|
910
|
+
message: `权限错误: ${error.message}${urlSuffix}`
|
|
695
911
|
};
|
|
696
912
|
}
|
|
697
913
|
return {
|
|
698
914
|
type: 'FILE_SYSTEM',
|
|
699
|
-
message: `文件系统错误: ${error.message}${
|
|
915
|
+
message: `文件系统错误: ${error.message}${urlSuffix}`
|
|
700
916
|
};
|
|
701
917
|
}
|
|
702
918
|
// 验证相关错误
|
|
703
|
-
if (errorMessage.includes('invalid') ||
|
|
704
|
-
errorMessage.includes('
|
|
705
|
-
errorMessage.includes('格式') ||
|
|
706
|
-
errorMessage.includes('无效')) {
|
|
919
|
+
if (errorMessage.includes('invalid') || errorMessage.includes('validation') ||
|
|
920
|
+
errorMessage.includes('格式') || errorMessage.includes('无效')) {
|
|
707
921
|
return {
|
|
708
922
|
type: 'VALIDATION',
|
|
709
|
-
message: `验证错误: ${error.message}${
|
|
923
|
+
message: `验证错误: ${error.message}${urlSuffix}`
|
|
710
924
|
};
|
|
711
925
|
}
|
|
712
926
|
// 用户取消
|
|
@@ -719,13 +933,14 @@ class Downloader {
|
|
|
719
933
|
// 其他错误
|
|
720
934
|
return {
|
|
721
935
|
type: 'UNKNOWN',
|
|
722
|
-
message: `未知错误: ${error.message}${
|
|
936
|
+
message: `未知错误: ${error.message}${urlSuffix}`
|
|
723
937
|
};
|
|
724
938
|
}
|
|
725
939
|
// 非 Error 对象
|
|
940
|
+
const urlSuffix = url ? ` (URL: ${url})` : '';
|
|
726
941
|
return {
|
|
727
942
|
type: 'UNKNOWN',
|
|
728
|
-
message: `未知错误: ${String(error)}${
|
|
943
|
+
message: `未知错误: ${String(error)}${urlSuffix}`
|
|
729
944
|
};
|
|
730
945
|
}
|
|
731
946
|
/**
|
|
@@ -753,8 +968,9 @@ class Downloader {
|
|
|
753
968
|
return this._createErrorResult(`无效的 base64 data URL: 缺少数据或扩展名 (URL: ${url.substring(0, 50)}...)`, 'VALIDATION');
|
|
754
969
|
}
|
|
755
970
|
try {
|
|
756
|
-
//
|
|
757
|
-
const
|
|
971
|
+
// 优化:直接解码 base64 数据,避免重复创建 Buffer
|
|
972
|
+
const buffer = Buffer.from(base64Info.data, 'base64');
|
|
973
|
+
const dataLength = buffer.length;
|
|
758
974
|
// 模拟进度回调(base64 数据通常很小,立即完成)
|
|
759
975
|
if (progressCallback) {
|
|
760
976
|
progressCallback({
|
|
@@ -765,8 +981,9 @@ class Downloader {
|
|
|
765
981
|
speed: 0
|
|
766
982
|
});
|
|
767
983
|
}
|
|
768
|
-
// 保存 base64
|
|
769
|
-
|
|
984
|
+
// 保存 base64 数据到文件(直接使用已解码的 buffer)
|
|
985
|
+
// 使用 Buffer.from 创建新的 Buffer 实例,确保类型兼容
|
|
986
|
+
await fs.promises.writeFile(outputPath, Buffer.from(buffer));
|
|
770
987
|
// 获取文件大小并返回成功结果
|
|
771
988
|
const stats = fs.statSync(outputPath);
|
|
772
989
|
const result = this._createSuccessResult(outputPath, stats.size);
|
|
@@ -795,6 +1012,10 @@ class Downloader {
|
|
|
795
1012
|
* - 如果文件已存在,添加数字后缀:filename(1).ext, filename(2).ext, ...
|
|
796
1013
|
* - 最多尝试 MAX_FILENAME_COUNTER 次,防止无限循环
|
|
797
1014
|
*
|
|
1015
|
+
* 性能优化:
|
|
1016
|
+
* - 使用二分查找优化查找范围(如果文件很多)
|
|
1017
|
+
* - 限制最大检查次数
|
|
1018
|
+
*
|
|
798
1019
|
* 示例:
|
|
799
1020
|
* - "test.pdf" -> "test.pdf" (如果不存在)
|
|
800
1021
|
* - "test.pdf" -> "test(1).pdf" (如果 test.pdf 已存在)
|
|
@@ -814,14 +1035,29 @@ class Downloader {
|
|
|
814
1035
|
return fileName;
|
|
815
1036
|
}
|
|
816
1037
|
// 如果存在,尝试添加数字后缀
|
|
1038
|
+
// 优化:使用线性查找,但限制最大检查次数
|
|
817
1039
|
let counter = 1;
|
|
818
|
-
let newFileName
|
|
1040
|
+
let newFileName = `${nameWithoutExt}(${counter})${ext}`;
|
|
819
1041
|
let newPath;
|
|
820
|
-
|
|
1042
|
+
// 快速查找:先尝试常见的数字(1-10)
|
|
1043
|
+
while (counter <= 10 && counter < MAX_FILENAME_COUNTER) {
|
|
821
1044
|
newFileName = `${nameWithoutExt}(${counter})${ext}`;
|
|
822
1045
|
newPath = path.join(dir, newFileName);
|
|
1046
|
+
if (!fs.existsSync(newPath)) {
|
|
1047
|
+
return newFileName;
|
|
1048
|
+
}
|
|
823
1049
|
counter++;
|
|
824
|
-
}
|
|
1050
|
+
}
|
|
1051
|
+
// 如果前10个都被占用,继续查找(但限制最大次数)
|
|
1052
|
+
while (counter < MAX_FILENAME_COUNTER) {
|
|
1053
|
+
newFileName = `${nameWithoutExt}(${counter})${ext}`;
|
|
1054
|
+
newPath = path.join(dir, newFileName);
|
|
1055
|
+
if (!fs.existsSync(newPath)) {
|
|
1056
|
+
return newFileName;
|
|
1057
|
+
}
|
|
1058
|
+
counter++;
|
|
1059
|
+
}
|
|
1060
|
+
// 如果达到最大次数,返回最后一个尝试的文件名
|
|
825
1061
|
return newFileName;
|
|
826
1062
|
}
|
|
827
1063
|
/**
|
|
@@ -979,7 +1215,7 @@ class Downloader {
|
|
|
979
1215
|
outputDir = path.resolve(options.outputDir);
|
|
980
1216
|
}
|
|
981
1217
|
else {
|
|
982
|
-
outputDir =
|
|
1218
|
+
outputDir = this._getDownloadsPath();
|
|
983
1219
|
}
|
|
984
1220
|
// 验证输出目录是否有效
|
|
985
1221
|
if (!outputDir || outputDir.trim().length === 0) {
|