方案一:通过Electron的DownloadItem
- 支持点击链接下载
- 支持输入文件名称和选择下载路径后,手动下载
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'
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
}
const speedDataMap = new Map<string, SpeedCalculationData>()
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(),
url,
filename: filenameToUse,
status: 'no-start',
savePath: savePathToUse,
fileSize: item.getTotalBytes(),
downloadedBytes: 0,
speedBytesPerSecond: 0
}
recordStore.addNewDownloadRecord(record)
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, {})
})
const setupDownloadEvents = (key: string) => {
const item = downloadItemMap.get(key)
if (item) {
speedDataMap.set(key, {
lastTimestamp: Date.now(),
lastBytesReceived: 0
})
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
})
} 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)),
savePath: item.savePath
})
sendDownloadRecords()
}
})
item.on('done', (_event, 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 {
finalStatus = 'interrupted'
}
recordStore.updateDownloadRecord(key, {
status: finalStatus,
downloadedBytes: item.getReceivedBytes(),
fileSize: item.getTotalBytes(),
speedBytesPerSecond: 0
})
}
downloadItemMap.delete(key)
speedDataMap.delete(key)
sendDownloadRecords()
recordStore.persistRecords()
})
}
}
ipcMain.on(IpcEvents.DOWNLOAD_FILE, (_event, options: DownLoadFile.DownloadOptions) => {
session.defaultSession.downloadURL(options.url)
})
ipcMain.handle(IpcEvents.CANCEL_DOWNLOAD, async (_event, key: string) => {
const item = downloadItemMap.get(key)
if (item) {
item.cancel()
}
return true
})
ipcMain.handle(IpcEvents.PAUSE_DOWNLOAD, async (_event, key: string) => {
const item = downloadItemMap.get(key)
if (item) {
item.pause()
}
})
ipcMain.handle(IpcEvents.RESUME_DOWNLOAD, async (_event, key: string) => {
const item = downloadItemMap.get(key)
if (item) {
item.resume()
const speedData = speedDataMap.get(key)
if (speedData) {
const now = Date.now()
speedData.lastTimestamp = now
speedData.lastBytesReceived = item.getReceivedBytes()
}
return
}
const record = recordStore.findDownloadRecord(key)
if (record && !item && record.status === 'paused') {
const filePath = resolve(record.savePath)
if (existsSync(filePath)) {
unlinkSync(filePath)
}
session.defaultSession.downloadURL(record.url)
}
})
app.on('before-quit', () => {
downloadItemMap.forEach((item) => {
item.cancel()
})
speedDataMap.clear()
recordStore.persistRecords()
})
}
方案二:通过worker_thread方案,基于nodejs相关网络库的流式下载
- 拦截默认下载
- 支持手动下载
- 支持应用退出后续传
- 扩展性更强
主进程
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) => {
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)
})
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 || '',
fileSize: 0,
status: 'no-start',
downloadedBytes: 0
}
recordStore.addNewDownloadRecord(record)
downloadWorkerManager.createWorker({
action: 'download',
record
})
downloadWorkerManager.on('message', (message) => {
messageHandler(message)
})
}
}
worker
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()
}
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)
}
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(/\/+$/, '')
if (filename) {
return filename
}
return filename
} catch (e) {
console.log('get filename from url error')
}
return null
}
extractFilenameFromContentDisposition(contentDisposition: string): string | null {
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())
}
const encodedFilenameMatch = contentDisposition.match(
/filename[^;=\n]*\*=(?:UTF-8|utf-8)''(.+)/i
)
if (encodedFilenameMatch) {
try {
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') {
}
})
} catch (err: any) {
downloadWorker.sendError(err.message || '下载异常')
}
})(workerData as DownLoadFile.DownloadFileWorkerData)