在 Electron 中实现微信第三方授权(绑定/登录)的完整方案
在 Electron 中实现微信第三方授权(绑定/登录)的完整方案
一、技术背景
在传统的 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.htmlvssocialLogin.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 机制,实现了安全、健壮且用户体验良好的第三方授权功能。核心思路是:
- 在主进程中创建授权窗口,利用
BrowserWindow的导航事件监听能力 - 通过 IPC 机制传递授权结果,避免跨域和安全问题
- 统一的回调处理函数,同时兼容浏览器和 Electron 环境
- 完善的安全机制,使用白名单、沙箱模式等保护应用安全
这个方案不仅适用于微信授权,也可以轻松扩展到其他第三方登录(如 GitHub、Google 等)场景。
希望这份技术总结对你有帮助!如果有任何问题或需要补充的内容,请随时告诉我。
更多推荐
所有评论(0)