要求隐藏 Electron 应用里的真实下载地址,记录一下怎么搞定的(顺带把下载进度也补上了)

      发布在:个人笔记      评论:0 条评论

最近在维护一个 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,其实就是临时文件早下完了,挪过去而已,正常现象。

总结

三个点:

  1. 藏地址靠 will-download + setSaveDialogOptions,弹窗标题自己定;
  2. 进度用 updated 事件 + setProgressBar + 改窗口标题,结果用 done 事件弹框;
  3. 记得用 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'(用户取消保存)不提示
        });
    });


热门推荐