@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.
@@ -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
  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
- const savePath = await this._showSaveDialog(url, options, defaultExt);
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
- // 验证 outputPath 是否有效
131
- if (!outputPath || typeof outputPath !== 'string' || outputPath.trim().length === 0) {
132
- return this._createErrorResult('无法确定输出路径:路径解析结果无效', 'VALIDATION');
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 = 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
- const tempFilePath = path.join(downloadDir, downloadFileName + '.cache');
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 DownloaderHelper(url, downloadDir, {
188
- fileName: downloadFileName + '.cache',
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
- if (progressCallback) {
205
- dl.on('progress', (stats) => {
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
- url,
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
- fs.rename(tempFilePath, outputPath, (renameErr) => {
227
- if (renameErr) {
228
- // 文件重命名失败:可能是权限问题或文件被占用
229
- const parsedError = this._parseError(renameErr, url);
230
- const error = new Error(`文件重命名失败: 无法将临时文件 "${tempFilePath}" 重命名为 "${outputPath}"。${parsedError.message}`);
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
- else {
416
+ });
417
+ }
418
+ else {
419
+ // 将临时文件重命名为最终文件(使用异步操作,避免阻塞)
420
+ fs.promises.rename(tempFilePath, outputPath).then(async () => {
241
421
  try {
242
- // 获取文件大小并返回成功结果
243
- const stats = fs.statSync(outputPath);
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
- const handleError = (error, isUserCancel = false) => {
264
- // 从下载列表中移除
265
- this._downloadingUrls.delete(downloadKey);
266
- // 清理临时文件(忽略删除失败,避免影响错误处理)
267
- fs.promises.unlink(tempFilePath).catch(() => {
268
- // 临时文件删除失败不影响错误处理流程
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
- // 调用错误回调
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
- // reject 应该传入 Error 对象,而不是 DownloadResult
285
- reject(errorObj);
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
- const error = err instanceof Error ? err : new Error(`下载失败: ${url}`);
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 = 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
- if (!fs.existsSync(dirPath)) {
411
- return false;
412
- }
413
- // 检查是否是目录(而非文件)
613
+ // 优化:使用 statSync 同时检查存在性和类型,然后检查权限
614
+ // 这样可以减少文件系统调用次数
414
615
  const stats = fs.statSync(dirPath);
415
- if (!stats.isDirectory()) {
416
- return false;
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
- return false;
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
- // 检查 _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;
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 || app.getPath('downloads');
462
680
  }
463
681
  // 如果指定了目录,检查文件是否存在并生成唯一文件名
464
- if (defaultDir) {
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
- if (this._isDirectoryAccessible(selectedDir)) {
716
+ if (isMas) {
717
+ // MAS 环境下,直接保存用户选择的目录,信任系统返回的路径
498
718
  this._lastSelectedDir = selectedDir;
499
719
  }
500
720
  else {
501
- // 如果选择的目录不可访问,尝试使用下载目录
502
- const downloadsPath = app.getPath('downloads');
503
- this._lastSelectedDir = this._isDirectoryAccessible(downloadsPath) ? downloadsPath : null;
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?.toLowerCase() || '';
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('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')) {
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}${url ? ` (URL: ${url})` : ''}`
899
+ message: `网络错误: ${error.message}${urlSuffix}`
679
900
  };
680
901
  }
681
902
  // 文件系统相关错误
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')) {
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}${url ? ` (URL: ${url})` : ''}`
910
+ message: `权限错误: ${error.message}${urlSuffix}`
695
911
  };
696
912
  }
697
913
  return {
698
914
  type: 'FILE_SYSTEM',
699
- message: `文件系统错误: ${error.message}${url ? ` (URL: ${url})` : ''}`
915
+ message: `文件系统错误: ${error.message}${urlSuffix}`
700
916
  };
701
917
  }
702
918
  // 验证相关错误
703
- if (errorMessage.includes('invalid') ||
704
- errorMessage.includes('validation') ||
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}${url ? ` (URL: ${url})` : ''}`
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}${url ? ` (URL: ${url})` : ''}`
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)}${url ? ` (URL: ${url})` : ''}`
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 dataLength = Buffer.from(base64Info.data, 'base64').length;
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
- await this._saveBase64ToFile(base64Info.data, outputPath);
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
- do {
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
- } while (fs.existsSync(newPath) && counter < MAX_FILENAME_COUNTER);
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 = app.getPath('downloads');
1218
+ outputDir = this._getDownloadsPath();
983
1219
  }
984
1220
  // 验证输出目录是否有效
985
1221
  if (!outputDir || outputDir.trim().length === 0) {