【Electron】解决 Electron 32 通过 webview 嵌套 HTTPS 页面无法触发下载的问题

技术栈:Electron 32.3.3 + React 18.3.1 + TypeScript

适用场景:webview 嵌套的 HTTPS 外部页面中,下载按钮通过 <a target="_blank"> 方式触发下载链接,点击后无法弹出下载对话框。


目录


问题背景

开发基于 Electron 的 Windows 桌面应用,核心需求是通过 webview 嵌套一个 HTTPS 外部页面。该页面包含下载按钮,要求用户点击后:

  1. 弹出文件保存对话框,支持自定义下载路径;
  2. 默认下载路径为系统桌面。

通过分析外部页面源码,发现其下载按钮的实现方式为:给 <a> 标签动态注入 href 下载链接,并通过 target="_blank" 打开新窗口触发下载。

为便于复现问题,本文以 OpenOffice 下载页面(https://www.openoffice.org/download/)为例,其下载按钮的核心特征如下:

OpenOffice 下载页面示例

关键要点:

  • <a> 标签设置了 target="_blank",在浏览器环境中点击会自动打开新窗口触发下载;
  • <a> 标签的 href 属性会被注入最终的下载链接(如 .pdf、.dmg、.exe 等文件链接)。

问题分析

在 Electron 应用中,用户点击 webview 嵌套页面内的下载按钮后,无法触发下载操作,且控制台无任何报错信息。

经过排查,问题根因如下:

  1. webview 拥有独立的 session:webview 默认使用 persist: 分区,与主窗口的 session 隔离。主进程对主窗口 session 监听的 will-download 事件无法覆盖 webview 会话。
  2. Electron 21+ 移除了 new-window 事件:webview 上的 addEventListener('new-window') 不再生效,新窗口事件需通过主进程的 setWindowOpenHandler 处理。
  3. a 标签的 target="_blank" 触发的是新窗口:这不会产生下载事件,而是产生 window-open 事件,需要先拦截该事件,再手动调用 downloadURL() 触发下载。

失败尝试

尝试一:主进程监听 will-download 事件

由于需求要求用户手动选择下载路径并指定默认路径,首先想到通过主进程监听窗口的 will-download 事件来拦截并处理下载:

// 主进程 — 监听主窗口 session 的下载事件
win.webContents.session.on('will-download', (event, item, _webContents) => {
  const filePath = path.join(app.getPath('desktop'), item.getFilename());

  console.log('Downloading:', item.getFilename());
  item.setSavePath(filePath);

  item.on('updated', (_updateEvent, state) => {
    if (state === 'progressing') {
      if (item.isPaused()) {
        console.log('Download paused');
      } else {
        console.log(`Received bytes: ${item.getReceivedBytes()}`);
      }
    }
  });

  item.on('done', (_doneEvent, state) => {
    if (state === 'completed') {
      console.log('Download successfully:', filePath);
    } else {
      console.log('Download failed:', state);
    }
  });
});

结果:代码中第一条 console.log 未打印,说明 will-download 事件未被触发。

补充验证:使用 VS Code 官网下载链接(https://code.visualstudio.com/)测试,上述代码可正常监听下载事件,说明方法本身无误,问题在于 webview 嵌套页面的下载触发机制与主窗口不同。

VS Code 下载页面示例

失败原因:webview 使用独立 session,主进程对主窗口 session 的 will-download 监听无法捕获 webview 内部的下载行为。

尝试二:监听 webview 的 new-window 事件

结合外部页面下载按钮的实现逻辑(target="_blank" 打开新窗口),推测问题可能是 webview 新窗口事件未被拦截,导致下载链接未被主进程捕获。因此尝试监听 webview 的 new-window 事件:

const webviewRef = useRef<HTMLElement | null>(null);

const handleNewWindow = useCallback((e: Event) => {
  e.preventDefault();
  console.log('将要触发新窗口', e);
}, []);

const webviewRefCallback = useCallback(
  (node: HTMLElement | null) => {
    if (webviewRef.current) {
      const prev = webviewRef.current as any;
      prev.removeEventListener('new-window', handleNewWindow);
    }
    webviewRef.current = node;
    if (node) {
      node.addEventListener('new-window', handleNewWindow);
    }
  },
  [handleNewWindow],
);

{iframeUrl && (
  <webview
    ref={webviewRefCallback as any}
    src={iframeUrl}
    {...({ allowpopups: 'true' } as Record<string, string>)}
    style={{ width: '100%', height: '100%' }}
  />
)}

结果:handleNewWindow 中的 console.log 同样未输出,事件未被触发。

查阅 Electron 官方文档确认:

  • Electron 21+ 版本已移除 new-window 事件;
  • webview 上通过 addEventListener('new-window') 方式不再生效;
  • Electron 32+ 版本中,webview 的新窗口事件需通过主进程的 setWindowOpenHandler 处理。

失败原因:使用了已废弃的事件 API,且未在主进程侧注册 setWindowOpenHandler。

失败总结

尝试方案失败原因
主进程监听 will-downloadwebview 使用独立 session,主窗口的监听无法覆盖
监听 new-window 事件Electron 21+ 已移除该事件,API 不再生效

核心结论:必须获取 webview 的 guest 实例,在其独立 session 上绑定下载监听,并通过 setWindowOpenHandler 拦截新窗口事件手动触发下载。


最终方案

方案架构

┌─────────────────────────────────────────────────────────┐
│                     渲染进程 (React)                      │
│                                                         │
│  webview ── did-attach ──→ getWebContentsId()           │
│                              │                          │
│                    window.electronAPI.registerWebview()  │
│                              │                          │
└──────────────────────────────┼──────────────────────────┘
                               │ IPC: other-pages:register-webview
┌──────────────────────────────┼──────────────────────────┐
│                     主进程 (Electron)                     │
│                              │                          │
│              webContents.fromId(webContentsId)           │
│                              │                          │
│                    guest (webview 实例)                   │
│                    ┌────────┴────────┐                  │
│                    │                 │                  │
│          setWindowOpenHandler    session.on('will-download')
│                    │                 │                  │
│          拦截 target="_blank"    弹出保存对话框          │
│          guest.downloadURL()     监听下载进度/结果       │
│                    │                 │                  │
│                    └────────┬────────┘                  │
│                             │                           │
│                        文件下载完成                       │
└─────────────────────────────────────────────────────────┘

步骤一:渲染进程 — webview 事件监听与 IPC 通信

核心实现:监听 webview 的 did-attach 事件,获取 webContentsId 并通过 IPC 发送到主进程;同时处理加载状态、网络异常等场景。

import { Button, Result, Spin } from 'antd';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';

/**
 * 外部页面嵌套组件 — 使用 webview 嵌入 HTTPS 页面
 */
const OtherPages: FC = () => {
  const { t } = useTranslation();
  const { pathname } = useLocation();
  const [loading, setLoading] = useState(true);
  const [loadFailed, setLoadFailed] = useState(false);
  const [offline, setOffline] = useState(!navigator.onLine);
  const webviewRef = useRef<HTMLElement | null>(null);
  const mountedRef = useRef(true);

  // 测试用外部页面 URL(实际项目替换为目标页面)
  const iframeUrl = 'https://www.openoffice.org/download/';

  // 路由切换时重置加载状态
  useEffect(() => {
    if (iframeUrl) {
      setLoading(true);
      setLoadFailed(false);
    }
  }, [iframeUrl]);

  // webview 加载完成回调
  const handleLoaded = useCallback(() => {
    if (!mountedRef.current) return;
    setLoading(false);
    setLoadFailed(false);
  }, []);

  // webview 加载失败回调(仅处理主框架失败)
  const handleLoadFailed = useCallback((e: Event) => {
    if (!mountedRef.current) return;
    const detail = (e as CustomEvent).detail || (e as any);
    // 忽略子框架(内嵌 iframe、广告等)的加载失败
    if (detail && detail.isMainFrame === false) return;
    setLoading(false);
    setLoadFailed(true);
  }, []);

  /**
   * webview 绑定完成回调:获取 webContentsId 并注册到主进程
   *
   * 关键说明:
   * webview 默认使用独立 session(persist: 分区),
   * 需在主进程对其 session 单独绑定 will-download 监听。
   */
  const handleDidAttach = useCallback((e: Event) => {
    if (!mountedRef.current) return;
    const wv = e.currentTarget as any;
    const getId = wv?.getWebContentsId;
    if (typeof getId !== 'function') return;

    const webContentsId = getId.call(wv) as number;
    if (webContentsId == null || webContentsId <= 0) return;

    // 调用主进程 API 注册 webview
    void window.electronAPI?.registerWebview?.(webContentsId);
  }, []);

  // 监听网络状态变化
  useEffect(() => {
    const handleOnline = () => setOffline(false);
    const handleOffline = () => setOffline(true);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  // 拦截 webview 内部未处理的 Promise 异常,避免阻塞页面导航
  useEffect(() => {
    const handler = (e: PromiseRejectionEvent) => {
      const msg = e.reason?.message || '';
      if (
        msg.includes('GUEST_VIEW_MANAGER_CALL') ||
        msg.includes('webview') ||
        msg.includes('WebContents')
      ) {
        e.preventDefault();
        console.warn('[OtherPages] 拦截 webview 异常:', msg);
      }
    };
    window.addEventListener('unhandledrejection', handler);
    return () => window.removeEventListener('unhandledrejection', handler);
  }, []);

  // webview 引用回调:绑定/解绑事件监听
  const webviewRefCallback = useCallback(
    (node: HTMLElement | null) => {
      // 解绑之前的 webview 事件监听
      if (webviewRef.current) {
        const prev = webviewRef.current as any;
        prev.removeEventListener('did-stop-loading', handleLoaded);
        prev.removeEventListener('did-fail-load', handleLoadFailed);
        prev.removeEventListener('did-attach', handleDidAttach);
      }

      webviewRef.current = node;

      // 给新的 webview 绑定事件监听
      if (node) {
        node.addEventListener('did-stop-loading', handleLoaded);
        node.addEventListener('did-fail-load', handleLoadFailed);
        node.addEventListener('did-attach', handleDidAttach);
      }
    },
    [handleLoaded, handleLoadFailed, handleDidAttach],
  );

  // 组件卸载:清理 webview 资源,避免内存泄漏
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      const node = webviewRef.current as any;
      if (node) {
        node.removeEventListener('did-stop-loading', handleLoaded);
        node.removeEventListener('did-fail-load', handleLoadFailed);
        node.removeEventListener('did-attach', handleDidAttach);
        try {
          node.stop?.();
        } catch {
          /* 忽略停止失败异常 */
        }
      }
    };
  }, [handleLoaded, handleLoadFailed, handleDidAttach]);

  return (
    <>
      {/* 加载中状态 */}
      {loading && (
        <Spin size="large" />
      )}

      {/* 离线状态 */}
      {offline && (
        <Result
          status="error"
          title={t('otherPages.offline')}
          subTitle={t('otherPages.offlineHint')}
          extra={
            <Button
              type="primary"
              onClick={() => {
                if (navigator.onLine) {
                  setOffline(false);
                  setLoadFailed(false);
                  setLoading(true);
                  const webview = webviewRef.current as any;
                  webview?.reload?.();
                }
              }}
            >
              {t('otherPages.reload')}
            </Button>
          }
        />
      )}

      {/* 加载失败状态 */}
      {!offline && loadFailed && iframeUrl && (
        <Result
          status="error"
          title={t('otherPages.loadFailed')}
          subTitle={t('otherPages.loadFailedRetryHint')}
          extra={
            <Button
              type="primary"
              onClick={() => {
                setLoadFailed(false);
                setLoading(true);
                const webview = webviewRef.current as any;
                webview?.reload?.();
              }}
            >
              {t('otherPages.reload')}
            </Button>
          }
        />
      )}

      {/* 渲染 webview 组件 */}
      {iframeUrl && (
        <webview
          ref={webviewRefCallback as any}
          src={iframeUrl}
          {...({ allowpopups: 'true' } as Record<string, string>)}
          style={{ width: '100%', height: '100%' }}
        />
      )}
    </>
  );
};

export default OtherPages;

步骤二:主进程 — 暴露注册 webview 的 API

在主进程的 bridge.js(preload 桥接层)中,声明 registerWebview 方法,用于渲染进程与主进程通信:

const electronApi = {
  /**
   * 注册 webview,将 webContentsId 传递到主进程
   * @param webContentsId webview 的 webContentsId
   */
  registerWebview: (webContentsId: number) =>
    invoke('other-pages:register-webview', webContentsId),
};

步骤三:主进程 — 注册 webview 并拦截新窗口

在主进程的 windowReady 生命周期阶段,注册 IPC 事件处理器,通过 webContentsId 获取 webview 的 guest 实例,绑定下载监听并拦截新窗口:

import { ipcMain, webContents } from 'electron';
import { bindWillDownloadToSession } from '../service/sessionDownload';

/**
 * 处理 webview 注册逻辑
 *
 * 核心说明:
 * - webview 拥有独立 session(persist: 分区),主窗口的 will-download 监听无法覆盖
 * - 必须在主进程获取 guest 实例后,对其 session 单独绑定监听
 */
ipcMain.handle(
  'other-pages:register-webview',
  (_event: Electron.IpcMainInvokeEvent, webContentsId: unknown) => {
    // 校验 webContentsId 有效性
    if (typeof webContentsId !== 'number' || webContentsId <= 0) {
      logger.warn('[common] 忽略无效的 webContentsId:', webContentsId);
      return false;
    }

    // 通过 webContentsId 获取 webview 实例(guest 实例)
    const guest = webContents.fromId(webContentsId);
    if (!guest || guest.isDestroyed()) {
      logger.warn('[common] webview 实例不存在或已销毁', { webContentsId });
      return false;
    }

    // 1. 给 webview 的独立 session 绑定下载监听
    bindWillDownloadToSession(guest.session, 'other-pages-webview');

    // 2. 拦截 webview 内部的 window.open / target="_blank" 事件
    guest.setWindowOpenHandler((details) => {
      const { url, frameName, disposition } = details;
      logger.info('[other-pages-webview] 拦截新窗口打开', {
        url,
        frameName,
        disposition,
      });

      // 验证 URL 有效性,通过 downloadURL 手动触发下载
      // (下载会走 guest.session 的 will-download 监听)
      if (url && /^https?:\/\//i.test(url)) {
        try {
          guest.downloadURL(url);
        } catch (err) {
          logger.error('[other-pages-webview] 手动触发下载失败', err as Error);
        }
      }

      // 禁止打开新窗口
      return { action: 'deny' };
    });

    return true;
  },
);

步骤四:主进程 — 封装 session 下载监听工具

创建 sessionDownload.ts,封装 bindWillDownloadToSession 方法,用于给指定 session 绑定 will-download 事件:

import { app, type Session } from 'electron';
import { logger } from 'ee-core/log';
import path from 'path';

// 避免重复给同一个 session 绑定下载监听
const sessionsWithDownloadHandler = new WeakSet<Session>();

/**
 * 给指定 Session 绑定 will-download 事件
 *
 * 核心说明:
 * webview 默认使用独立内存分区(persist:),
 * 其下载事件走自身 session,需单独绑定监听。
 *
 * @param session 目标 session(webview 的 guest.session)
 * @param logTag  日志标记,用于区分不同场景的下载日志
 */
export function bindWillDownloadToSession(
  session: Session,
  logTag: string,
): void {
  // 避免重复绑定
  if (sessionsWithDownloadHandler.has(session)) {
    return;
  }
  sessionsWithDownloadHandler.add(session);

  session.on('will-download', (_event, item) => {
    // 配置下载保存对话框:指定标题、默认路径、按钮文本
    item.setSaveDialogOptions({
      title: '选择下载位置',
      defaultPath: path.join(app.getPath('desktop'), item.getFilename()),
      buttonLabel: '保存',
    });

    logger.info(`[${logTag}] 开始下载`, { filename: item.getFilename() });

    // 监听下载进度更新
    item.on('updated', (_updateEvent, state) => {
      if (state === 'progressing' && !item.isPaused()) {
        logger.debug(`[${logTag}] 下载进度`, {
          filename: item.getFilename(),
          received: item.getReceivedBytes(),
        });
      }
    });

    // 监听下载完成/失败
    item.on('done', (_doneEvent, state) => {
      if (state === 'completed') {
        logger.info(`[${logTag}] 下载完成`, { path: item.getSavePath() });
      } else {
        logger.warn(`[${logTag}] 下载结束(非成功)`, {
          state,
          filename: item.getFilename(),
        });
      }
    });
  });
}

核心原理总结

整个方案的核心思路可以用四步概括:

1. 渲染进程监听 webview 的 did-attach 事件
   ↓ 获取 webContentsId,通过 IPC 发送到主进程

2. 主进程通过 webContents.fromId() 获取 guest 实例
   ↓

3. guest 实例注册 setWindowOpenHandler
   ↓ 拦截 target="_blank" 的新窗口事件
   ↓ 调用 guest.downloadURL(url) 手动触发下载

4. guest.session 绑定 will-download 事件
   ↓ 弹出保存对话框,监听下载进度和结果

精简示例代码:

// 渲染进程:监听 did-attach,获取 webContentsId
webview.addEventListener('did-attach', () => {
  const webContentsId = webview.getWebContentsId();
  ipcRenderer.send('webview-attached', webContentsId);
});
// 主进程:注册 guest 实例,拦截新窗口 + 绑定下载监听
ipcMain.on('webview-attached', (_event, webContentsId) => {
  const guest = webContents.fromId(webContentsId);

  // 拦截新窗口,手动触发下载
  guest.setWindowOpenHandler(({ url }) => {
    guest.downloadURL(url);
    return { action: 'deny' };
  });

  // 绑定 session 下载监听
  guest.session.on('will-download', (_event, item) => {
    item.setSaveDialogOptions({
      title: '选择下载位置',
      defaultPath: path.join(app.getPath('desktop'), item.getFilename()),
      buttonLabel: '保存',
    });
  });
});

注意事项

  1. 本方案仅适用于 <a> 标签直接绑定最终下载链接的场景(非中转页面)。如果下载链接经过服务端中转(如先请求接口返回下载地址),需要额外处理中转逻辑。

  2. 测试网站的下载校验限制:本文使用的 OpenOffice 下载页面,因网站自身有下载校验,点击下载后可能会下载页面 HTML 而非目标文件。这是目标网站的下载机制限制,不影响本方案的通用性。

  3. "网页解析失败"问题:测试中发现 OpenOffice 部分下载链接会出现"网页解析失败"报错,这是目标网站自身的链接或解析异常,与本方案无关,可更换测试链接验证。

  4. session 重复绑定防护:使用 WeakSet 避免对同一 session 重复绑定 will-download 监听,防止内存泄漏和事件重复触发。

  5. webview 生命周期管理:组件卸载时务必解绑事件监听并调用 stop() 停止加载,避免内存泄漏。


如果有更优的实现方案,欢迎在评论区交流讨论!

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐