Electron中的下载操作

方案一:通过Electron的DownloadItem

  • 支持点击链接下载
  • 支持输入文件名称和选择下载路径后,手动下载
typescript 复制代码
import { IpcEvents } from '@interfaces/ipc-events'
import { DownLoadFile } from '@interfaces/types'
import { DownloadFileStore, getUniqueFilename } from '@utils/download'
import { app, dialog, ipcMain, session } from 'electron'
import { existsSync, unlinkSync } from 'node:fs'
import { resolve } from 'node:path'
import { v4 as uuidv4 } from 'uuid'
import { WindowsManager } from './windows'

/**
 * 设置基于 Electron DownloadItem 的文件下载管理。
 * 监听会话的 'will-download' 事件来捕获下载项,
 * 并为每个下载项设置事件监听器以跟踪其状态和计算速度。
 * 同时注册 IPC 处理程序来控制下载(开始、取消、暂停、恢复)。
 */
export const setupDownloadFileByDownloadItem = () => {
  // 存储下载记录的实例
  const recordStore = new DownloadFileStore()
  // 存储下载项对象的映射,键为下载记录的唯一标识符
  const downloadItemMap = new Map<string, Electron.DownloadItem>()
  const windowsManager = WindowsManager.getInstance()
  const pendingDownloadOptions = new Map<string, { savePath?: string; filename?: string }>()

  // 定义一个接口来存储速度计算所需的历史数据
  interface SpeedCalculationData {
    lastTimestamp: number // 上次采样时间戳 (毫秒)
    lastBytesReceived: number // 上次采样时已接收的字节数
    // 可以扩展以存储更多历史点用于平滑计算
  }

  // 使用 Map 来存储每个下载项的速度计算数据
  const speedDataMap = new Map<string, SpeedCalculationData>()

  /**
   * 监听会话的 'will-download' 事件。
   * 当一个新的下载请求被发起时,此事件会被触发。
   * @param _event - Electron 事件对象
   * @param item - Electron DownloadItem 对象,代表当前的下载任务
   */
  session.defaultSession.on('will-download', async (_event, item): Promise<boolean | void> => {
    const url = item.getURL()
    const options = pendingDownloadOptions.get(url)
    let savePathToUse = app.getPath('downloads') // 默认保存路径为下载文件夹
    let filenameToUse = item.getFilename() // 默认使用服务器提供的文件名
    if (options) {
      savePathToUse = options.savePath || app.getPath('downloads')
      filenameToUse = options.filename || item.getFilename()
    }
    filenameToUse = getUniqueFilename(savePathToUse, filenameToUse)
    const filePath = resolve(savePathToUse, filenameToUse)
    item.setSavePath(filePath) // 设置下载文件的保存路径

    // 创建一个新的下载记录对象
    const record: DownLoadFile.DownloadRecord = {
      key: uuidv4(), // 使用 uuid 生成唯一标识符
      url, // 获取下载链接
      filename: filenameToUse, // 获取文件名
      status: 'no-start', // 初始状态为未开始
      savePath: savePathToUse, // 获取保存路径 (可能为空,由对话框决定)
      fileSize: item.getTotalBytes(), // 获取文件总大小
      downloadedBytes: 0, // 已下载字节数初始化为 0
      speedBytesPerSecond: 0 // 初始速度为 0
    }

    // 将新记录添加到存储中
    recordStore.addNewDownloadRecord(record)
    // 将 DownloadItem 对象存入 Map,方便后续通过 key 查找
    downloadItemMap.set(record.key, item)

    // 为这个特定的下载项设置事件监听器
    setupDownloadEvents(record.key)

    sendDownloadRecords()
  })

  const sendDownloadRecords = () => {
    windowsManager.receiveDownloadInfoWindows.forEach((win) => {
      win.webContents.send(IpcEvents.SEND_DOWNLOAD_RECORDS, recordStore.downloadRecords)
    })
  }

  ipcMain.handle(IpcEvents.OPEN_FILE_DIALOG, async (_, oldPath?: string) => {
    oldPath = oldPath ? oldPath : recordStore.downloadFileDir
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openDirectory', 'createDirectory'],
      filters: [
        {
          name: 'All Files',
          extensions: ['*']
        }
      ],
      title: '选择保存位置',
      defaultPath: oldPath
    })
    if (canceled || !filePaths || filePaths.length === 0) {
      return oldPath
    } else {
      const newPath = filePaths[0]
      recordStore.downloadFileDir = newPath
      return newPath
    }
  })

  ipcMain.handle(IpcEvents.GET_DOWNLOAD_RECORDS, () => {
    return recordStore.downloadRecords
  })
  ipcMain.handle(IpcEvents.DELETE_DOWNLOAD, (_event, record: DownLoadFile.DownloadRecord) => {
    recordStore.removeDownloadRecord(record.key)
    const filePath = record.savePath
    if (existsSync(filePath)) {
      unlinkSync(filePath)
    }
    sendDownloadRecords()
    return true
  })
  ipcMain.handle(IpcEvents.DOWNLOAD_FILE, (_event, options: DownLoadFile.DownloadOptions) => {
    const { savePath, url, filename } = options
    pendingDownloadOptions.set(url, { savePath, filename })
    session.defaultSession.downloadURL(url, {})
  })

  /**
   * 为指定 key 的 DownloadItem 设置事件监听器 ('updated' 和 'done')。
   * @param key - 下载记录的唯一标识符
   */
  const setupDownloadEvents = (key: string) => {
    const item = downloadItemMap.get(key)
    if (item) {
      // 初始化该下载项的速度计算数据
      speedDataMap.set(key, {
        lastTimestamp: Date.now(),
        lastBytesReceived: 0 // 初始为 0
      })

      /**
       * 监听 DownloadItem 的 'updated' 事件。
       * 当下载进度更新或状态改变时触发。
       * @param _ - Electron 事件对象
       * @param state - 下载状态 ('progressing', 'interrupted')
       */
      item.on('updated', (_event, state) => {
        const record = recordStore.findDownloadRecord(key)
        if (!record) return // 安全检查

        if (state === 'interrupted') {
          // 如果下载被中断,更新记录状态为暂停或取消
          const status: DownLoadFile.DownloadRecord['status'] = item.isPaused()
            ? 'paused'
            : 'cancelled'
          recordStore.updateDownloadRecord(key, {
            status: status,
            speedBytesPerSecond: 0, // 中断时速度归零
            savePath: item.savePath
          })
          // 可以选择在此清理 speedDataMap,但如果用户可能恢复,则保留
          // speedDataMap.delete(key);
        } else if (state === 'progressing') {
          // 如果下载正在进行,更新记录的状态、已下载字节数等信息
          const currentBytes = item.getReceivedBytes()
          const totalBytes = item.getTotalBytes()
          const currentTimestamp = Date.now()

          let speedBytesPerSecond = 0
          const speedData = speedDataMap.get(key)

          if (speedData) {
            const timeDiffSeconds = (currentTimestamp - speedData.lastTimestamp) / 1000 // 转换为秒

            // 避免除以零或负数时间差
            if (timeDiffSeconds > 0) {
              const bytesDiff = currentBytes - speedData.lastBytesReceived
              // 计算瞬时速度 (字节/秒)
              speedBytesPerSecond = bytesDiff / timeDiffSeconds
            }

            // 更新速度计算数据为当前值,供下次计算使用
            speedData.lastTimestamp = currentTimestamp
            speedData.lastBytesReceived = currentBytes
          } else {
            // 如果没有速度数据(理论上不应该发生,但做安全检查)
            // 重新初始化
            speedDataMap.set(key, {
              lastTimestamp: currentTimestamp,
              lastBytesReceived: currentBytes
            })
          }

          // 更新记录,包括速度
          recordStore.updateDownloadRecord(key, {
            status: 'progressing',
            downloadedBytes: currentBytes,
            fileSize: totalBytes, // 文件大小可能在下载过程中更新
            speedBytesPerSecond: Math.max(0, Math.round(speedBytesPerSecond)), // 确保非负,并四舍五入为整数 B/s
            savePath: item.savePath
          })
          sendDownloadRecords()
        }
      })

      /**
       * 监听 DownloadItem 的 'done' 事件。
       * 当下载完成、取消或失败时触发。
       */
      item.on('done', (_event, state) => {
        // 注意:Electron 30+ 'done' 事件回调签名已更改,包含 state 参数
        // state 可能是 'completed', 'cancelled', 'interrupted'
        // 为了兼容性和准确性,使用 state 参数
        const record = recordStore.findDownloadRecord(key)
        console.log(item.getSavePath(), item.getFilename())
        if (record) {
          let finalStatus: DownLoadFile.DownloadRecord['status'] = 'interrupted' // 默认
          if (state === 'completed') {
            finalStatus = 'complete'
          } else if (state === 'cancelled') {
            finalStatus = 'cancelled'
          } else {
            // 'interrupted' or unknown
            finalStatus = 'interrupted'
          }

          // 根据 DownloadItem 的最终状态更新记录
          recordStore.updateDownloadRecord(key, {
            status: finalStatus,
            downloadedBytes: item.getReceivedBytes(),
            fileSize: item.getTotalBytes(),
            speedBytesPerSecond: 0 // 完成后速度为 0
          })
        }
        // 下载结束后,从 Maps 中移除对应的引用
        downloadItemMap.delete(key)
        speedDataMap.delete(key) // 清除速度计算数据

        sendDownloadRecords()
        recordStore.persistRecords()
      })
    }
  }

  /**
   * 监听 IPC 事件 IpcEvents.DOWNLOAD_FILE。
   * 当渲染进程请求开始下载一个文件时触发。
   * @param _event - IPC 事件对象
   * @param options - 下载选项,包含 URL
   */
  ipcMain.on(IpcEvents.DOWNLOAD_FILE, (_event, options: DownLoadFile.DownloadOptions) => {
    // 调用 session 的 downloadURL 方法开始下载
    session.defaultSession.downloadURL(options.url)
  })

  /**
   * 处理 IPC 事件 IpcEvents.CANCEL_DOWNLOAD。
   * 当渲染进程请求取消一个下载时触发。
   * @param _event - IPC 事件对象
   * @param key - 要取消的下载记录的唯一标识符
   * @returns Promise<boolean> - 操作结果 (总是返回 true)
   */
  ipcMain.handle(IpcEvents.CANCEL_DOWNLOAD, async (_event, key: string) => {
    const item = downloadItemMap.get(key)
    if (item) {
      // 调用 DownloadItem 的 cancel 方法取消下载
      item.cancel()
    }
    // 返回操作结果
    return true
  })

  /**
   * 处理 IPC 事件 IpcEvents.PAUSE_DOWNLOAD。
   * 当渲染进程请求暂停一个下载时触发。
   * @param _event - IPC 事件对象
   * @param key - 要暂停的下载记录的唯一标识符
   */
  ipcMain.handle(IpcEvents.PAUSE_DOWNLOAD, async (_event, key: string) => {
    const item = downloadItemMap.get(key)
    if (item) {
      // 调用 DownloadItem 的 pause 方法暂停下载
      item.pause()
    }
    // 无返回值
  })

  /**
   * 处理 IPC 事件 IpcEvents.RESUME_DOWNLOAD。
   * 当渲染进程请求恢复一个下载时触发。
   * @param _event - IPC 事件对象
   * @param key - 要恢复的下载记录的唯一标识符
   */
  ipcMain.handle(IpcEvents.RESUME_DOWNLOAD, async (_event, key: string) => {
    const item = downloadItemMap.get(key)
    if (item) {
      // 如果 DownloadItem 存在,直接调用 resume 方法恢复下载
      item.resume()
      // 恢复时重置速度计算的基准点,以避免速度突增
      const speedData = speedDataMap.get(key)
      if (speedData) {
        const now = Date.now()
        speedData.lastTimestamp = now
        speedData.lastBytesReceived = item.getReceivedBytes()
      }
      return
    }

    // 如果 DownloadItem 不存在(可能因为应用重启),但记录存在且状态为暂停
    const record = recordStore.findDownloadRecord(key)
    if (record && !item && record.status === 'paused') {
      // 检查本地是否已存在部分下载的文件
      const filePath = resolve(record.savePath)
      if (existsSync(filePath)) {
        // 如果存在,先删除它,然后重新开始下载
        unlinkSync(filePath)
      }
      // 重新调用 session 的 downloadURL 方法开始下载
      session.defaultSession.downloadURL(record.url)
    }
    // 无返回值
  })

  /**
   * 监听 app 的 'before-quit' 事件。
   * 在应用退出前,取消所有正在进行的下载任务。
   */
  app.on('before-quit', () => {
    // 遍历所有 DownloadItem 并调用 cancel 方法
    downloadItemMap.forEach((item) => {
      item.cancel()
    })
    // 清理速度数据 Map
    speedDataMap.clear()
    recordStore.persistRecords()
  })
}

方案二:通过worker_thread方案,基于nodejs相关网络库的流式下载

  • 拦截默认下载
  • 支持手动下载
  • 支持应用退出后续传
  • 扩展性更强

主进程

typescript 复制代码
// setupDownloadFileV2.ts
import { app, ipcMain, session } from 'electron'
import { IpcEvents } from '@interfaces/ipc-events'
import { DownloadFileStore, DownloadWorkerManager } from '@utils/download'
import type { DownLoadFile } from '@interfaces/types'
import { v4 as uuidV4 } from 'uuid'
import { WindowsManager } from './windows'
import { debounce } from 'lodash'

export async function setupDownloadFileV2() {
  await app.whenReady()
  const recordStore = new DownloadFileStore()
  const downloadWorkerManager = new DownloadWorkerManager()
  session.defaultSession.on('will-download', (event, item) => {
    // electron的默认下载行为,这里拦截并阻止,使用自定义下载行为
    const filename = item.getFilename()
    const url = item.getURL()
    const savePath = item.savePath || app.getPath('downloads')
    handleDownload({ url, savePath, filename: filename })
    event.preventDefault()
    return false
  })
  ipcMain.handle(IpcEvents.GET_DOWNLOAD_RECORDS, () => {
    return recordStore.downloadRecords
  })
  // 开启一个下载任务
  ipcMain.handle(IpcEvents.DOWNLOAD_FILE, async (_event, options: DownLoadFile.DownloadOptions) => {
    handleDownload(options)
  })
  // 继续下载 todo : 断点续传
  ipcMain.handle(IpcEvents.RESUME_DOWNLOAD, async (_event, record: DownLoadFile.DownloadRecord) => {
    handleResume(record)
  })
  // 取消下载
  ipcMain.handle(IpcEvents.CANCEL_DOWNLOAD, async (event, record: DownLoadFile.DownloadRecord) => {
    const worker = downloadWorkerManager.getWorker(record.key)
    if (worker) {
      worker.postMessage({ type: 'cancel', data: { key: record.key } })
    }
    event.sender.send(IpcEvents.SEND_DOWNLOAD_RECORDS, recordStore.downloadRecords)
  })
  // 暂停下载
  ipcMain.handle(IpcEvents.PAUSE_DOWNLOAD, async (_event, record: DownLoadFile.DownloadRecord) => {
    const worker = downloadWorkerManager.getWorker(record.key)
    if (worker) {
      worker.postMessage({ type: 'pause', data: { key: record.key } })
    }
  })
  ipcMain.handle(IpcEvents.DELETE_DOWNLOAD, (_event, record: DownLoadFile.DownloadRecord) => {
    const worker = downloadWorkerManager.getWorker(record.key)
    if (worker) {
      worker.postMessage({ type: 'cancel', data: { key: record.key } })
    }
    recordStore.removeDownloadRecord(record.key)
    WindowsManager.getInstance().receiveDownloadInfoWindows.forEach((window) => {
      window.webContents.send(IpcEvents.SEND_DOWNLOAD_RECORDS, recordStore.downloadRecords)
    })
  })

  const messageHandler = debounce((message: DownLoadFile.DownloadFileResult) => {
    const { key, ...rest } = message.data
    recordStore.updateDownloadRecord(key, rest)
    if (message.type === 'progress') {
      WindowsManager.getInstance().receiveDownloadInfoWindows.forEach((window) => {
        window.webContents.send(IpcEvents.DOWNLOAD_FILE_PROGRESS, message.data)
      })
    }
    if (['error', 'cancelled', 'paused', 'complete'].includes(message.type)) {
      downloadWorkerManager.removeWorker(message.data.key)
    }
    if (['error', 'cancelled', 'paused', 'complete', 'begin'].includes(message.type)) {
      WindowsManager.getInstance().receiveDownloadInfoWindows.forEach((window) => {
        window.webContents.send(IpcEvents.SEND_DOWNLOAD_RECORDS, recordStore.downloadRecords)
      })
    }
  })

  const handleResume = (record: DownLoadFile.DownloadRecord) => {
    downloadWorkerManager.createWorker({
      action: 'resume',
      record
    })
    downloadWorkerManager.on('message', (message: DownLoadFile.DownloadFileResult) => {
      messageHandler(message)
    })
  }

  const handleDownload = (options: DownLoadFile.DownloadOptions) => {
    const record: DownLoadFile.DownloadRecord = {
      url: options.url,
      savePath: options.savePath ?? app.getPath('downloads'),
      key: uuidV4(),
      filename: options.filename || '',
      // 初次创建时,文件大小为0,下载状态为未开始,后续通过网络获取
      fileSize: 0,
      status: 'no-start',
      downloadedBytes: 0
    }
    recordStore.addNewDownloadRecord(record)
    downloadWorkerManager.createWorker({
      action: 'download',
      record
    })
    downloadWorkerManager.on('message', (message) => {
      messageHandler(message)
    })
  }
}

worker

typescript 复制代码
import { DownLoadFile } from '@interfaces/types'
import { workerData, parentPort } from 'node:worker_threads'
import axios, { AxiosResponse } from 'axios'
import { basename, dirname, join, parse } from 'node:path'
import { createWriteStream, existsSync, mkdirSync, statSync, unlinkSync } from 'node:fs'
import { IncomingMessage } from 'node:http'
import { EventEmitter } from 'node:events'

class DownloadWorker extends EventEmitter {
  private supportsRange = true
  private abortCtrl = new AbortController()
  private currentState: 'running' | 'paused' | 'cancelled' = 'running'
  static DOWN_CHUNK_SIZE = 1024 * 1024
  constructor(private record: DownLoadFile.DownloadRecord) {
    super()
  }

  get key() {
    return this.record.key
  }
  get filename() {
    return this.record.filename
  }
  set filename(value: string) {
    this.record.filename = value
  }
  get savePath() {
    return this.record.savePath
  }
  get url() {
    return this.record.url
  }
  get fileSize() {
    return this.record.fileSize
  }
  set fileSize(value: number) {
    this.record.fileSize = value
  }
  get downloadedBytes() {
    return this.record.downloadedBytes
  }
  set downloadedBytes(value: number) {
    this.record.downloadedBytes = value
  }

  async beginDownload() {
    try {
      this.currentState = 'running'
      await this.ensureFileBaseInfo()
      await this.download()
    } catch (err: any) {
      if (axios.isCancel(err)) {
        console.log(`Download cancelled for ${this.key}`)
      } else {
        console.error(`Download failed for ${this.key}:`, err.message)
        this.emit('error', err)
      }
    }
  }

  cancel() {
    if (this.currentState === 'cancelled') {
      console.warn(`Download ${this.key} is already cancelled`)
      return
    }
    this.currentState = 'cancelled'
    this.abortCtrl.abort()
    this.deleteFile()
  }

  pause() {
    if (this.currentState !== 'running') {
      console.warn(`Cannot pause download ${this.key}, current state is ${this.currentState}`)
      return
    }
    this.currentState = 'paused'
    this.abortCtrl.abort()
  }
  /**
   *
   * @description 恢复下载,备用api,为了后续暂停时不退出worker时使用
   */
  async resume() {
    this.currentState = 'running'
    this.abortCtrl = new AbortController()
    if (existsSync(this.filePath)) {
      this.downloadedBytes = statSync(this.filePath).size
      this._download()
    } else {
      // 如果文件不存在,重新开始下载
      await this.beginDownload()
    }
  }

  get filePath() {
    return join(this.savePath, this.filename)
  }
  /**
   * @description 删除文件,在取消下载是会调用
   */
  deleteFile() {
    const filepath = this.filePath
    if (existsSync(filepath)) {
      unlinkSync(filepath)
    }
  }

  async download() {
    const dir = dirname(this.savePath)
    const filepath = this.filePath
    if (existsSync(dir)) {
      mkdirSync(dir, { recursive: true })
    }
    if (existsSync(filepath)) {
      const stats = statSync(filepath)
      this.downloadedBytes = stats.size
      if (this.downloadedBytes === this.fileSize) {
        this.sendComplete()
        return
      }
      // 如果本地文件大于远程文件,可能有问题,从头开始
      if (this.downloadedBytes > this.fileSize) {
        unlinkSync(filepath)
        this.downloadedBytes = 0
      }
    }
    console.log(`Starting/Resuming download for ${this.key} from byte ${this.downloadedBytes}`)
    console.log(filepath, this.filename)
    this._download()
  }

  private async _download() {
    const writeStream = createWriteStream(this.filePath, {
      flags: this.downloadedBytes === 0 ? 'w' : 'a'
    })
    try {
      const response: AxiosResponse<IncomingMessage> = await axios.get(this.url, {
        responseType: 'stream',
        headers: {
          Range: this.supportsRange ? `bytes=${this.downloadedBytes}-` : undefined
        },
        signal: this.abortCtrl.signal
      })
      const downloadStream = response.data
      downloadStream.on('data', (chunk) => {
        if (this.currentState !== 'running') return
        this.downloadedBytes += chunk.length
        this.sendProgress()
      })
      downloadStream.on('error', (err) => {
        // 尝试关闭写入流
        writeStream.destroy(err)
        if (this.currentState === 'paused') {
          this.sendPaused()
        } else if (this.currentState === 'cancelled') {
          this.sendCancelled()
        } else {
          console.error(`Download stream failed for key ${this.key}:`, err.message)
          this.sendError(err.message)
        }
      })
      writeStream.on('error', (err) => {
        console.error(`Write stream error for key ${this.key}:`, err.message)
        if (downloadStream) {
          downloadStream.destroy()
        }
        if (this.currentState === 'paused') {
          this.sendPaused()
        } else if (this.currentState === 'cancelled') {
          this.sendCancelled()
        } else {
          this.sendError((err as Error).message)
        }
      })
      writeStream.on('finish', () => {
        if (this.currentState === 'running') {
          console.log(`Download completed for key ${this.key}: ${this.filePath}`)
          this.sendComplete()
        }
      })
      downloadStream.pipe(writeStream)
    } catch (err: any) {
      console.log(`Download failed for key ${this.key}: ${err.message}`)
      if (this.currentState === 'paused') {
        this.sendPaused()
      } else if (this.currentState === 'cancelled') {
        this.sendCancelled()
      } else {
        this.sendError(err.message)
      }
    }
  }

  private getFileNameFromContentDispositionOrUrl(
    contentDisposition: string | undefined,
    url: string
  ) {
    if (contentDisposition) {
      let filename = this.extractFilenameFromContentDisposition(contentDisposition)
      if (filename) {
        return filename
      }
    }
    const filename = this.getFileNameFromUrl(url)
    if (filename) {
      return filename
    }
    // 返回默认名称
    return Date.now().toString()
  }
  getFileNameFromUrl(fileUrl: string) {
    try {
      const parseUrl = new URL(fileUrl)
      const pathname = parseUrl.pathname
      let filename = basename(pathname)
      // 去除可能的末尾 '/'
      filename = filename.replace(/\/+$/, '')

      // 如果路径是像 /download/123 这样的 ID,basename 可能无扩展名
      // 但至少返回它
      if (filename) {
        return filename
      }
      return filename
    } catch (e) {
      console.log('get filename from url error')
    }
    return null
  }
  extractFilenameFromContentDisposition(contentDisposition: string): string | null {
    // 匹配 filename="..."
    const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i)
    if (filenameMatch) {
      let filename = filenameMatch[1]
      // 移除包裹的引号
      if (filename.startsWith('"') && filename.endsWith('"')) {
        filename = filename.slice(1, -1)
      }
      return decodeURIComponent(filename.trim())
    }

    // 匹配 filename*=UTF-8''...
    const encodedFilenameMatch = contentDisposition.match(
      /filename[^;=\n]*\*=(?:UTF-8|utf-8)''(.+)/i
    )
    if (encodedFilenameMatch) {
      try {
        // URL 解码(处理百分号编码)
        return decodeURIComponent(encodedFilenameMatch[1])
      } catch (e) {
        console.warn('Failed to decode URI component:', encodedFilenameMatch[1])
        return null
      }
    }
    return null
  }

  getUniqueFilename(
    directory: string,
    filename: string,
    suffixTemplate: string = ' (%d)' // 可自定义模板
  ): string {
    const { name, ext } = parse(filename)
    let candidate = filename
    let counter = 1

    while (existsSync(join(directory, candidate))) {
      candidate = `${name}${suffixTemplate.replace('%d', counter.toString())}${ext}`
      counter++
    }

    return candidate
  }

  initFilename(response: AxiosResponse) {
    this.filename = this.filename
      ? this.filename
      : this.getFileNameFromContentDispositionOrUrl(
          response.headers['content-disposition'],
          this.url
        )
    this.filename = this.getUniqueFilename(this.savePath, this.filename)
  }

  async ensureFileBaseInfo() {
    try {
      const response = await axios.head(this.url)
      if (response.status >= 200 && response.status < 300) {
        this.initFilename(response)
        const contentLength = response.headers['content-length']
        if (contentLength) {
          this.fileSize = Number(contentLength)
          this.supportsRange = response.headers['accept-ranges'] === 'bytes'
          console.log(
            `File size for ${this.key}: ${this.fileSize}, Supports Range: ${this.supportsRange}`
          )

          this.sendBegin()
        } else {
          throw new Error('Content-Length header not found in HEAD response')
        }
      } else if (response.status === 404) {
        throw new Error('File not found (404)')
      } else {
        throw new Error(`Failed to get file info, status: ${response.status}`)
      }
    } catch (err: any) {
      console.warn(`HEAD request failed for ${this.key}, trying GET for headers:`, err.message)
      try {
        const getResponse = await axios.get(this.url, {
          responseType: 'stream'
        })

        this.initFilename(getResponse)
        const contentLength = getResponse.headers['content-length']
        if (contentLength) {
          this.fileSize = Number(contentLength)
          this.supportsRange = getResponse.headers['accept-ranges'] === 'bytes'
          console.log(
            `(GET fallback) File size for ${this.key}: ${this.fileSize}, Supports Range: ${this.supportsRange}`
          )
          getResponse.data.destroy()
          this.sendBegin()
        } else {
          getResponse.data.destroy()
          throw new Error('Content-Length header not found in GET response (fallback)')
        }
      } catch (getErr: any) {
        console.error(`GET request (fallback) also failed for ${this.key}:`, getErr.message)
        throw new Error(
          `Could not determine file size: HEAD failed (${err.message}), GET fallback failed (${getErr.message})`
        )
      }
    }
  }

  sendPaused() {
    this.record = { ...this.record, status: 'paused' }
    const event: DownLoadFile.DownloadFileResult = {
      type: 'paused',
      data: this.record
    }
    this.emit('paused', event)
  }

  sendCancelled() {
    this.record = { ...this.record, status: 'cancelled' }
    const event: DownLoadFile.DownloadFileResult = {
      type: 'cancelled',
      data: this.record
    }
    this.emit('cancelled', event)
  }

  sendBegin() {
    this.record = { ...this.record, status: 'progressing' }
    const event: DownLoadFile.DownloadFileResult = {
      type: 'begin',
      data: this.record
    }
    this.emit('begin', event)
  }

  sendError(err: string) {
    this.record = { ...this.record, status: 'error' }
    const event: DownLoadFile.DownloadFileResult = {
      type: 'error',
      data: this.record,
      message: err
    }
    this.emit('error', event)
  }
  sendComplete() {
    this.record = { ...this.record, status: 'complete' }
    const event: DownLoadFile.DownloadFileResult = {
      type: 'complete',
      data: this.record
    }
    this.emit('complete', event)
  }

  sendProgress() {
    this.record = { ...this.record, status: 'progressing' }
    const event: DownLoadFile.DownloadFileResult = {
      type: 'progress',
      data: this.record
    }
    this.emit('progress', event)
  }
}

;(async (data: DownLoadFile.DownloadFileWorkerData) => {
  const { action, record } = data
  const downloadWorker = new DownloadWorker(record)
  try {
    downloadWorker.on('progress', (event: DownLoadFile.DownloadFileResult) => {
      parentPort?.postMessage(event)
    })
    downloadWorker.on('complete', (event: DownLoadFile.DownloadFileResult) => {
      parentPort?.postMessage(event)
    })
    downloadWorker.on('error', (event: DownLoadFile.DownloadFileResult) => {
      parentPort?.postMessage(event)
    })
    downloadWorker.on('paused', (event: DownLoadFile.DownloadFileResult) => {
      parentPort?.postMessage(event)
    })
    downloadWorker.on('cancelled', (event: DownLoadFile.DownloadFileResult) => {
      parentPort?.postMessage(event)
    })
    if (action === 'download') {
      downloadWorker.beginDownload()
    } else {
      downloadWorker.resume()
    }
    parentPort?.on('message', (event: DownLoadFile.ToDownloadWorkerEvent) => {
      if (event.type === 'cancel') {
        downloadWorker.cancel()
      } else if (event.type === 'pause') {
        downloadWorker.pause()
      } else if (event.type === 'resume') {
        // 由于目前是发生异常则退出该worker所以resume暂时不实现
      }
    })
  } catch (err: any) {
    downloadWorker.sendError(err.message || '下载异常')
  }
})(workerData as DownLoadFile.DownloadFileWorkerData)
相关推荐
sjin2 小时前
React 源码 - Commit Phase 的工作细节
前端
FisherYu2 小时前
AI环境搭建pytorch+yolo8搭建
前端·计算机视觉
学前端搞口饭吃3 小时前
react reducx的使用
前端·react.js·前端框架
aidingni8883 小时前
掌握 JavaScript 中的 Map 和 Set
前端·javascript
开心不就得了3 小时前
React 进阶
前端·javascript·react.js
Olrookie3 小时前
ruoyi-vue(十四)——前端框架及package.json,vite.config.js, main.js文件介绍
前端·笔记
jeff渣渣富3 小时前
Taro 2.x 分包优化实践:如何防止分包文件被打包到主包
前端·javascript
谢尔登3 小时前
【React】React 哲学
前端·react.js·前端框架
wow_DG3 小时前
【Vue2 ✨】Vue2 入门之旅 · 进阶篇(八):Vuex 内部机制
前端·javascript·vue.js