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)
相关推荐
skiyee4 小时前
换个底座的 Wot Starter 居然让 AI 更懂项目!
前端·ai编程
anyup4 小时前
这一套绝了!AI 大模型写故事,星云 SDK 驱动 3D 数字人实时演绎
前端·人工智能·aigc
耀耀切克闹灬4 小时前
短链跳转
前端
孟陬4 小时前
高质量全方位的 AI 工具 sse-stuntman — Mock SSE 接口
前端·node.js·openai
Cao_Ron4 小时前
给 ECharts 6 graph 节点叠加角标:从原理到性能优化
前端
WebGirl5 小时前
内网穿透方式
前端
hoLzwEge5 小时前
前端项目的基石:深入解读 package.json 的作用与核心配置
前端·前端框架
奇奇怪怪的5 小时前
Agentic RAG:Agent 驱动的自主检索
前端
陆枫Larry5 小时前
页面接口换图后不刷新,刷新页面后才看得到新图
前端
0x865 小时前
# Flutter 实时语音识别工程实践:从音频采集到流式 ASR 的完整链路
前端