最近在维护一个 Electron 套壳的项目,就是把一个网站包成 exe 给客户用的那种。前几天客户直接甩过来一张截图提需求:用户在页面上点下载按钮,弹出来的"另存为"窗口,最顶上的标题栏赫然写着完整的下载地址,类似这样:
http://xxx-com.img.addlink.cn/ruanjian/Ledconfig20260225.zip
客户的原话是"点下载显示真实下载地址了,隐藏或者加密"。确实,CDN 地址、目录结构、文件名全给用户看光了,有心人拿着这个地址稍微改改,说不定还能猜出别的文件路径,客户不乐意很正常。
查了下,这个其实是 Chromium 的默认行为,Electron 只是继承了而已。默认弹的那个保存对话框,标题就是下载来源的 URL,没得商量,只能自己动手拦。
解决:拦截 will-download,自己定弹窗标题
好在 Electron 给了口子,主进程里监听 session 的 will-download 事件,然后用 item.setSaveDialogOptions() 把弹窗标题换成自己想要的:
win.webContents.session.on('will-download', (event, item) => {
const fileName = item.getFilename();
item.setSaveDialogOptions({
title: '保存文件 - ' + fileName,
defaultPath: path.join(app.getPath('downloads'), fileName)
});
});就这么几行,弹窗标题从一长串 URL 变成了"保存文件 - Ledconfig20260225.zip",地址彻底看不到了。顺手把 defaultPath 也设了,弹窗默认就定位到用户的"下载"文件夹,文件名也预填好,体验还顺了一点。
注意这个 setSaveDialogOptions 不是特别老的 API,我项目用的 Electron 22 没问题,太老的版本自己查下文档。
对了,如果你连弹窗都不想要,点了直接静默下载到下载文件夹,那就更简单,一行 item.setSavePath(...) 指定路径,弹窗都不会出来。不过我这边客户要能自己选位置,所以没这么干。
顺手解决第二个问题:下载到底下完没有?
地址是藏住了,但试的时候又发现一个老问题:点了保存之后,整个应用一点反应都没有。下了多少、下完没有,全靠猜。文件大点的话用户真的会以为软件卡死了。
Electron 的下载 item 上有个 updated 事件,能拿到已下载字节和总字节,我就把进度怼到两个地方:
一个是任务栏图标上的绿色进度条(Windows 特性,win.setProgressBar()),另一个是窗口标题,直接显示百分比:
item.on('updated', (event, state) => {
if (state === 'progressing' && !item.isPaused()) {
const received = item.getReceivedBytes();
const total = item.getTotalBytes();
if (total > 0) {
const progress = received / total;
win.setProgressBar(progress);
win.setTitle(`下载中 ${Math.round(progress * 100)}% - ${fileName}`);
} else {
win.setProgressBar(2); // 拿不到总大小就显示不确定进度
}
}
});有个小细节,setProgressBar 传大于 1 的值(比如 2)就是那种来回滚动的不确定进度条,服务器不返回 Content-Length 拿不到总大小的时候就用这个。
下载结束再监听 done 事件,清掉进度条,弹个框告诉用户结果,成功的话还给个"打开文件位置"的按钮,点了直接打开资源管理器并选中文件:
item.once('done', (event, state) => {
win.setProgressBar(-1); // 传 -1 清除进度条
if (state === 'completed') {
dialog.showMessageBox(win, {
type: 'info',
title: '下载完成',
message: `文件下载完成:${fileName}`,
buttons: ['打开文件位置', '知道了']
}).then((result) => {
if (result.response === 0) {
shell.showItemInFolder(item.getSavePath());
}
});
} else if (state === 'interrupted') {
// 下载失败提示一下,用户主动取消的(cancelled)就别烦人家了
}
});state 有三种:completed 成功、interrupted 失败、cancelled 用户自己取消。取消的那种别弹提示,人家自己点的取消,你还弹个框出来就很烦。
踩坑:另存为弹窗还开着,进度就开始跳了
本来以为完事了,一测试发现体验很诡异:另存为弹窗还开着呢,我还没点保存,窗口标题就已经开始"下载中 30%、50%……"地跳了。
后来想明白了,Chromium 的下载机制是点击下载的那一瞬间就已经开始往临时文件里下了,另存为弹窗只是在问你"最终把文件挪到哪",它压根不会等你。所以 updated 事件在弹窗期间就一直在触发。
修复也简单,item.getSavePath() 在用户点"保存"之前一直是空字符串,拿这个当开关就行:
item.on('updated', (event, state) => {
if (!item.getSavePath()) return; // 还没确认保存路径,先不显示进度
// ...下面才是进度逻辑
});这样流程就顺了:点下载 → 弹窗(干干净净没地址、没进度)→ 点保存 → 进度条开始走 → 下完弹提示。
还有个衍生的小现象:文件小或者网速快的话,用户在弹窗里犹豫的功夫,文件其实已经在后台下完了,点保存的瞬间直接弹"下载完成"。第一次看到还以为是 bug,其实就是临时文件早下完了,挪过去而已,正常现象。
总结
三个点:
- 藏地址靠
will-download+setSaveDialogOptions,弹窗标题自己定; - 进度用
updated事件 +setProgressBar+ 改窗口标题,结果用done事件弹框; - 记得用
getSavePath()是否为空来判断用户确认保存没有,不然弹窗没关进度就开跑,很出戏。
都是小改动,但对套壳应用来说观感差别挺大的。记录完毕,下次别的项目直接抄自己作业。
最后附上完整的实现代码
// 拦截下载,自定义“另存为”对话框标题,隐藏真实下载地址
win.webContents.session.on('will-download', (event, item) => {
const fileName = item.getFilename();
item.setSaveDialogOptions({
title: '保存文件 - ' + fileName,
defaultPath: path.join(app.getPath('downloads'), fileName)
});
// 下载进度:显示在任务栏图标上(绿色进度条),同时更新窗口标题
item.on('updated', (event, state) => {
// 用户还没在“另存为”弹窗中确认保存路径前,不显示进度
if (!item.getSavePath()) {
return;
}
if (state === 'progressing' && !item.isPaused()) {
const received = item.getReceivedBytes();
const total = item.getTotalBytes();
if (total > 0) {
const progress = received / total;
win.setProgressBar(progress); // 任务栏进度条
win.setTitle(`下载中 ${Math.round(progress * 100)}% - ${fileName}`);
} else {
win.setProgressBar(2); // 总大小未知,显示不确定进度
win.setTitle(`下载中... - ${fileName}`);
}
} else if (state === 'interrupted') {
win.setProgressBar(-1);
}
});
// 下载结束:清除进度条并提示结果
item.once('done', (event, state) => {
win.setProgressBar(-1); // 清除任务栏进度条
win.setTitle('LED大屏智能报价助手'); // 恢复窗口标题
if (state === 'completed') {
const savePath = item.getSavePath();
console.log(`[下载] 完成: ${savePath}`);
dialog.showMessageBox(win, {
type: 'info',
title: '下载完成',
message: `文件下载完成:${fileName}`,
buttons: ['打开文件位置', '知道了'],
defaultId: 0
}).then((result) => {
if (result.response === 0) {
shell.showItemInFolder(savePath); // 打开资源管理器并选中文件
}
});
} else if (state === 'interrupted') {
dialog.showMessageBox(win, {
type: 'error',
title: '下载失败',
message: `文件下载失败:${fileName},请重试`,
buttons: ['知道了']
});
}
// state === 'cancelled'(用户取消保存)不提示
});
});
产生原因:Electron的依赖包下载失败,其下载地址在国外,所以依赖没有下载成功。(仅仅配置 阿里云的registry是没有用的)解决办法:配置ELECTRON_MIRROR解决的方法就是在~/.n...