Electron BaseWindow 下载在 Windows 上静默失败,Mac 正常
一个桌面应用,点击"导出文件",前端弹出成功 toast"已开始下载",但文件从来没有落到磁盘上。macOS 完全正常,Windows 打包版一声不响——没有报错、没有日志、没有任何提示。
这类"静默失败"是最难排查的一类 bug:前端捕获不到,主进程也没有日志,测试同学只能一遍遍截图证明"文件就是没出来"。
现象
前端代码使用经典的 anchor 触发下载:
const anchor = document.createElement('a')
anchor.href = `/api/.../file` // 服务端返回 Content-Disposition: attachment
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()anchor.click() 立即返回,不抛异常,所以 try/catch 看不到错误,成功 toast 照常触发。
macOS 下弹出系统保存对话框,正常保存;Windows 打包版点击后什么都没发生,连系统对话框都没出现。
根因
两个条件叠加导致了这个跨平台不一致:
条件一:整个主进程没有注册任何 will-download 处理器。
grep -rn "will-download" src/main
# 零命中没有 will-download 处理,Electron 就走默认行为——弹出系统原生「另存为」对话框。
条件二:窗口壳用的是 BaseWindow + WebContentsView,而不是 BrowserWindow。
BaseWindow 本身没有根级 webContents,这是 Electron 30+ 推荐的新 API 架构。然而,默认的原生保存对话框是模态(modal)的,需要一个有效的父窗口句柄(HWND)才能弹出。
- macOS:对话框可以作为独立的 app-modal panel 出现,不强依赖父窗口 HWND,所以能弹出。
- Windows:必须绑定到一个有效 HWND,
BaseWindow提供不了这个句柄,对话框静默取消,整个下载链路就此中断。
这正好解释了"Mac 正常、Windows 不行"的对称现象。
anchor.click() 触发的下载失败不会抛出异常,也不会触发任何 Promise rejection。前端的成功回调照常执行,日志里看不到任何异常。这意味着你不能靠前端的 toast 或 try/catch 来判断下载是否真正成功——必须验证文件是否落盘。
用 BaseWindow + 无 will-download 的组合在 Windows 上,整个失败过程发生在 Electron 原生层,对 JS 层完全不可见。
正解
自己接管 will-download,调用 item.setSavePath() 显式指定保存路径,彻底绕开平台默认对话框的差异。
关键原则:
- 在
app.whenReady()里只注册一次,绑定到session.defaultSession - 不要在每次窗口创建/重建时重复注册,否则同一个 session 上会累积多个监听器,每次下载会保存 N 份文件
// download-host.ts
import { existsSync } from 'node:fs'
import path from 'node:path'
import { app, Notification, session, shell } from 'electron'
const ILLEGAL = /[<>:"/\\|?*]/g // Windows 非法字符;保留空格、连字符、中文
function safeName(name: string): string {
const s = (name || 'download').replace(ILLEGAL, '_').replace(/\.+$/, '').trim()
return s.length ? s : 'download'
}
function uniquePath(dir: string, filename: string): string {
const ext = path.extname(filename)
const base = path.basename(filename, ext)
let p = path.join(dir, filename)
let i = 1
while (existsSync(p)) {
p = path.join(dir, `${base} (${i})${ext}`)
i++
}
return p
}
export function installDownloadHandler(): void {
const downloads = app.getPath('downloads')
session.defaultSession.on('will-download', (_e, item) => {
const savePath = uniquePath(downloads, safeName(item.getFilename()))
item.setSavePath(savePath) // ← 这一行是关键修复
item.once('done', (_e2, state) => {
if (state === 'completed') {
new Notification({ title: '下载完成', body: '已保存到「下载」文件夹' })
.on('click', () => shell.showItemInFolder(savePath))
.show()
}
})
})
}在主进程入口调用:
app.whenReady().then(() => {
installDownloadHandler() // 必须在创建窗口之前或同步调用
createWindow()
})只要调用了 item.setSavePath(...),Electron 就不再尝试弹出「另存为」对话框,下载直接写入指定路径,跨平台行为完全一致。注册一次在 session.defaultSession 上,所有 anchor 下载、blob 下载、文件模板下载都能被这个处理器统一托管。
补充:文件名已预解码
item.getFilename() 会自动将 Content-Disposition: attachment; filename*=UTF-8''%E6%96%87%E4%BB%B6.xlsx 解码为 UTF-8 字符串,不需要手动 decodeURIComponent。只需要过滤 Windows 非法字符(< > : " / \ | ? *),中文、空格、连字符都可以保留。
一句话外卖
在
BaseWindow架构下,凡是依赖 Electron 原生 UI 的功能(保存对话框、打印对话框等),都需要手动接管——不能假设平台默认行为在两个系统上表现一致。