一、技术背景

在传统的 Web 应用中,微信授权通常通过 window.open() 打开授权页面,然后通过 postMessage 进行跨窗口通信。但在 Electron 应用中,我们需要使用 IPC(进程间通信)机制来实现主窗口和授权窗口之间的消息传递。

二、架构设计

我们的方案采用了主进程-渲染进程的通信架构:

渲染进程(Login/Bind页面)
    ↓ 调用 window.api.auth.openWindow()
主进程(ipc.ts)
    ↓ 创建授权窗口 BrowserWindow
授权窗口(微信授权页面)
    ↓ 监听重定向/导航事件
主进程(ipc.ts)
    ↓ 发送 IPC 消息到渲染进程
渲染进程(Login/Bind页面)
    ↓ 接收授权结果并调用后端 API

三、核心实现步骤

1. Preload 脚本配置(src/preload/index.ts

首先,我们需要在 preload 脚本中暴露安全的 API 给渲染进程:

const api = {
  // 授权窗口 API
  auth: {
    openWindow: (authUrl: string, redirectUri: string) =>
      ipcRenderer.invoke('auth:open-window', authUrl, redirectUri)
  },
  
  // 事件监听 API
  on: (channel: string, callback: (...args: any[]) => void) => {
    const validChannels = [
      'webview-message', 
      'bind-callback-result',    // 绑定回调
      'login-callback-result'    // 登录回调
    ]
    if (validChannels.includes(channel)) {
      ipcRenderer.on(channel, (_, ...args) => callback(...args))
    }
  },
  
  // 移除监听
  off: (channel: string, callback: (...args: any[]) => void) => {
    const validChannels = [
      'webview-message', 
      'bind-callback-result', 
      'login-callback-result'
    ]
    if (validChannels.includes(channel)) {
      ipcRenderer.removeListener(channel, (_, ...args) => callback(...args))
    }
  }
}

// 通过 contextBridge 安全地暴露给渲染进程
contextBridge.exposeInMainWorld('api', api)

关键点:

  • 使用白名单机制(validChannels)限制可监听的频道,确保安全性
  • 区分 bind-callback-result 和 login-callback-result 两种事件类型
  • 通过 contextBridge 而不是直接暴露 Node.js API
2. 主进程授权窗口创建(src/main/ipc.ts

在主进程中注册 IPC 处理器,负责创建授权窗口并监听回调:

ipcMain.handle('auth:open-window', (_event, authUrl: string, redirectUri: string) => {
  console.log('auth:open-window called:', { authUrl, redirectUri })
  
  return new Promise((resolve, reject) => {
    try {
      // 创建授权窗口
      const authWindow = new BrowserWindow({
        width: 600,
        height: 600,
        parent: mainWindow,
        modal: false,
        show: false,
        autoHideMenuBar: true,  // 隐藏菜单栏
        webPreferences: {
          nodeIntegration: false,
          contextIsolation: true,
          sandbox: true          // 启用沙箱模式,提高安全性
        }
      })

      // 窗口准备好后显示
      authWindow.once('ready-to-show', () => {
        authWindow.show()
      })

      // 加载授权 URL
      authWindow.loadURL(authUrl).catch((error) => {
        console.error('Failed to load URL:', error)
        authWindow.close()
        reject(error)
      })

      // 监听重定向事件(主要方案)
      authWindow.webContents.on('will-redirect', (event, url) => {
        handleCallback(event, url, true)
      })

      // 监听导航完成事件(备用方案)
      authWindow.webContents.on('did-navigate', (event, url) => {
        handleCallback(event, url, false)
      })

      // 回调处理函数
      function handleCallback(event: any, url: string, preventDefault: boolean) {
        try {
          const urlObj = new URL(url)
          
          // 判断是绑定还是登录
          const isCallbackUrl = 
            urlObj.pathname.includes('bind.html') || 
            urlObj.pathname.includes('socialLogin.html')
          
          let type = 'bind-callback-result'
          if (urlObj.pathname.includes('bind.html')) {
            type = 'bind-callback-result'
          } else if (urlObj.pathname.includes('socialLogin.html')) {
            type = 'login-callback-result'
          }
          
          const code = urlObj.searchParams.get('code')
          const state = urlObj.searchParams.get('state')

          console.log('Callback check:', { 
            pathname: urlObj.pathname, 
            isCallbackUrl, 
            hasCode: !!code, 
            type 
          })

          if (isCallbackUrl && code) {
            if (preventDefault) {
              event.preventDefault()
            }
            
            // 发送结果到主窗口渲染进程
            if (mainWindow && !mainWindow.isDestroyed()) {
              console.log('Sending to main window:', type, { code, state })
              mainWindow.webContents.send(type, {
                type: type,
                code,
                state,
                status: 'success'
              })
            }

            // 关闭授权窗口
            authWindow.close()
            resolve({ success: true })
          }
        } catch (error) {
          console.error('Failed to handle callback:', error)
        }
      }

      // 窗口关闭事件
      authWindow.on('closed', () => {
        console.log('Auth window closed')
      })

      // 处理用户手动关闭窗口的情况
      authWindow.on('close', () => {
        if (!authWindow.isDestroyed()) {
          resolve({ success: false, cancelled: true })
        }
      })
      
    } catch (error) {
      console.error('Failed to create auth window:', error)
      reject(error)
    }
  })
})

关键点:

  • 使用 will-redirect 和 did-navigate 双重监听,确保回调被捕获
  • 根据 URL 路径动态判断事件类型(bind.html vs socialLogin.html
  • 使用 preventDefault() 阻止重定向,提高用户体验
  • 通过 mainWindow.webContents.send() 将结果发送到主窗口
3. 渲染进程监听授权结果(以登录为例:src/core/ui/pages/useWechat.tsx

const useBindWechatDialog = ({ handleLoginCallback }) => {
  const [open, setOpen] = useState(false)
  let currentItem: any = {}

  // 打开微信授权窗口
  function openWechatLoginWindow(url: any, redirectUri: string) {
    const wechatLoginUrl = url

    // 检查是否在 Electron 环境
    if (window.api && window.api.auth) {
      // Electron 环境:使用主进程创建授权窗口
      console.log('调用 window.api.auth.openWindow:', { wechatLoginUrl, redirectUri })
      window.api.auth
        .openWindow(wechatLoginUrl, redirectUri)
        .then((result) => {
          console.log('授权窗口结果:', result)
        })
        .catch((error) => {
          console.error('打开授权窗口失败:', error)
          message.error('打开授权窗口失败')
        })
    } else {
      // 浏览器环境:使用 window.open
      let width = 600, height = 600
      let top = (window.screen.height - 30 - height) / 2
      let left = (window.screen.width - 30 - width) / 2
      window.open(
        wechatLoginUrl,
        '',
        `width=${width},height=${height},top=${top},left=${left},toolbar=no,menubar=no`
      )
    }
  }

  // 触发授权流程
  const handleDialogVisible = async (value: any, item: any = { type: 32 }) => {
    if (value) {
      currentItem = item
      
      // 构建回调地址
      let redirectUri = 'https://demo.jdyos.com/aa2_aios/socialLogin.html'
      if (process.env.NODE_ENV !== 'development') {
        let fullDomain = `${window.location.protocol}//${window.location.hostname}${
          window.location.port ? ':' + window.location.port : ''
        }`
        redirectUri = `${fullDomain}/aa2_aios/socialLogin.html`
      }

      // 获取授权 URL
      let res: any = await AuthApi.bindForCommonForLogin(
        `?type=${item.type}&redirectUri=${encodeURIComponent(redirectUri)}`
      )
      
      // 打开授权窗口
      openWechatLoginWindow(res.data, redirectUri)
    }
  }

  useEffect(() => {
    // 处理授权回调的统一函数(浏览器和 Electron 共用)
    const handleBindCallback = async (data: any) => {
      // 检查是否是登录回调且在登录页面
      if (data && data.type === 'login-callback-result' && location.href.includes('login')) {
        console.log('成功接收到授权结果:', data)
        
        if (data.status === 'error') {
          message.info(data.message)
        } else {
          // 构建回调地址(用于后端验证)
          let redirectUri: any = undefined
          if (currentItem.type === 50) {
            redirectUri = 'https://demo.jdyos.com/aa2_aios/socialLogin.html'
            if (process.env.NODE_ENV !== 'development') {
              let fullDomain = `${window.location.protocol}//${window.location.hostname}${
                window.location.port ? ':' + window.location.port : ''
              }`
              redirectUri = `${fullDomain}/aa2_aios/socialLogin.html`
            }
          }

          if (currentItem.type) {
            // 调用登录 API
            let res: any = await AuthApi.bindForCommonForLoginCodeState({
              type: currentItem.type,
              code: data.code,
              state: data.state,
              redirectUri
            })

            if (res.code === 0) {
              handleLoginCallback(res)  // 登录成功回调
            } else {
              message.warning(res.msg)
            }
          }
        }
      }
    }

    // 浏览器环境:监听 postMessage
    const messageHandler = (event: MessageEvent) => {
      handleBindCallback(event.data)
    }
    window.addEventListener('message', messageHandler, false)

    // Electron 环境:监听 IPC 消息
    const ipcHandler = (data: any) => {
      handleBindCallback(data)
    }
    if (window.api) {
      window.api.on('login-callback-result', ipcHandler)
    }

    // 清理函数
    return () => {
      window.removeEventListener('message', messageHandler)
      if (window.api) {
        window.api.off('login-callback-result', ipcHandler)
      }
    }
  }, [])

  return {
    wechatVisible: handleDialogVisible,
    wechatGetDom: getDom,
  }
}

关键点:

  • 统一的 handleBindCallback 函数同时兼容浏览器和 Electron 环境
  • 在浏览器中监听 postMessage,在 Electron 中监听 IPC 事件
  • 使用 location.href.includes('login') 确保只在正确的页面响应
  • 组件卸载时清理事件监听,防止内存泄漏
4. 绑定场景的实现(src/renderer/src/components/app/useBindWechatDialog.tsx

绑定场景与登录场景的实现基本相同,主要区别在于:

// 1. 监听的事件类型不同
window.api.on('bind-callback-result', ipcHandler)  // 绑定用这个

// 2. 调用的 API 不同
AuthApi.bindForCommonCodeState({  // 绑定用这个 API
  type: currentItem.type,
  code: data.code,
  state: data.state,
  redirectUri
})

// 3. 回调地址不同
redirectUri = 'https://demo.jdyos.com/aa2_aios/bind.html'  // 绑定用 bind.html

四、技术要点总结

1. 安全性考虑
  • ✅ 使用 contextBridge 暴露 API,避免直接暴露 Node.js 能力
  • ✅ 白名单机制限制可监听的 IPC 频道
  • ✅ 授权窗口启用沙箱模式(sandbox: true
  • ✅ 禁用 Node 集成(nodeIntegration: false
2. 兼容性处理
  • ✅ 同时支持浏览器和 Electron 环境
  • ✅ 浏览器环境使用 postMessage,Electron 使用 IPC
  • ✅ 通过 window.api 检测运行环境
3. 用户体验优化
  • ✅ 隐藏授权窗口菜单栏(autoHideMenuBar: true
  • ✅ 窗口准备好后再显示(ready-to-show 事件)
  • ✅ 使用 will-redirect 拦截重定向,避免页面跳转
  • ✅ 授权完成后自动关闭窗口
4. 健壮性保障
  • ✅ 双重监听机制(will-redirect + did-navigate
  • ✅ 完善的错误处理和日志记录
  • ✅ 组件卸载时清理事件监听
  • ✅ 处理用户手动关闭窗口的情况

五、流程图

用户点击微信登录/绑定
    ↓
调用后端 API 获取授权 URL
    ↓
window.api.auth.openWindow(authUrl, redirectUri)
    ↓
主进程创建 BrowserWindow 并加载授权 URL
    ↓
用户在授权窗口中完成微信扫码/授权
    ↓
微信重定向到 socialLogin.html?code=xxx&state=xxx
    ↓
主进程监听到 will-redirect 事件
    ↓
解析 URL,提取 code 和 state
    ↓
mainWindow.webContents.send('login-callback-result', { code, state })
    ↓
渲染进程 window.api.on('login-callback-result', callback)
    ↓
调用后端 API 验证 code/state 并完成登录
    ↓
登录成功,更新 UI

六、常见问题与解决方案

Q1: 为什么需要同时监听 will-redirect 和 did-navigate

A: will-redirect 在重定向发生前触发,可以使用 preventDefault() 阻止跳转,提供更好的用户体验。但某些情况下 will-redirect 可能不触发,因此 did-navigate 作为备用方案。

Q2: 为什么要区分 bind-callback-result 和 login-callback-result

A: 绑定和登录是两个不同的业务场景,回调地址(bind.html vs socialLogin.html)和后端 API 都不同。区分事件类型可以让各自的页面只响应自己的授权结果。

Q3: 如何确保只在正确的页面响应授权结果?

A: 通过 location.href.includes('login') 或类似条件判断当前页面,避免在错误的页面处理授权回调。

Q4: 授权窗口为什么要设置 parent: mainWindow

A: 设置父窗口后,授权窗口会始终显示在主窗口前面,且当主窗口最小化时授权窗口也会一起最小化,提供更好的用户体验。

七、完整的文件结构

src/
├── preload/
│   └── index.ts                    # Preload 脚本,暴露安全的 API
├── main/
│   └── ipc.ts                      # 主进程 IPC 处理器
├── core/ui/pages/
│   └── useWechat.tsx               # 登录页面的微信授权 Hook
└── renderer/src/components/app/
    └── useBindWechatDialog.tsx     # 绑定页面的微信授权 Hook

八、总结

这套方案充分利用了 Electron 的 IPC 机制,实现了安全、健壮且用户体验良好的第三方授权功能。核心思路是:

  1. 在主进程中创建授权窗口,利用 BrowserWindow 的导航事件监听能力
  2. 通过 IPC 机制传递授权结果,避免跨域和安全问题
  3. 统一的回调处理函数,同时兼容浏览器和 Electron 环境
  4. 完善的安全机制,使用白名单、沙箱模式等保护应用安全

这个方案不仅适用于微信授权,也可以轻松扩展到其他第三方登录(如 GitHub、Google 等)场景。


希望这份技术总结对你有帮助!如果有任何问题或需要补充的内容,请随时告诉我。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐