写在前面:审核大大对不起!不小心误删了!麻烦你在审核一遍 sorry
在做 To B 或重交互的 SaaS 产品时,我们经常会遇到这样的场景:用户通过浏览器访问了 Web 端(H5 模式),但其实本地已经安装了体验更好的 Electron 桌面客户端。
如果能自动检测并提示用户:“嘿,你本地有客户端,要不要直接切过去?”——并且点击后免登录、直接带参数跳转到对应页面,我看很多大厂软件都是这种基操。
今天就跟大家分享,我是如何从 0 到 1 落地这套Web 端检测 + 自定义协议唤起 + 无缝登录方案的。
一、先看效果:核心交互时序
整个方案的精髓在于“无感”和“顺滑”。我们不阻断用户的 Web 操作,而是通过非阻塞通知条引导。整体交互时序如下:
sequenceDiagram
participant User as 用户
participant Web as 浏览器 (Web端)
participant OS as 操作系统
participant App as Electron (桌面端)
User->>Web: 打开H5页面
activate Web
Note over Web: 延迟 2s 后静默检测
Web->>OS: iframe 尝试触发 my-protocol://detect
alt 本地已安装且运行中
OS-->>Web: 窗口极速失焦 (<500ms)
Web->>User: 通知提示:"检测到已运行,是否唤起?"
else 本地已安装未运行
OS-->>Web: 窗口慢速失焦 (>500ms)
Web->>User: 通知提示:"检测到已安装,是否启动?"
else 本地未安装
OS-->>Web: 超时无响应 (1500ms)
Note over Web: 静默失败,不打扰用户
end
User->>Web: 点击"唤起/启动"
Web->>OS: iframe 触发 my-protocol://launch?token=xxx&redirect=xxx
OS->>App: 系统拉起/激活桌面端
App->>App: 主进程解析 URL 拿到 Token
App->>App: 渲染进程复用 SSO 逻辑登录并跳转
二、Web 端:如何检测客户端?
浏览器出于安全限制,无法直接扫描用户电脑的注册表或硬盘。目前业界通用的做法是自定义协议探测 + 窗口失焦计时。
1. 核心探测原理(灵魂所在)
我们通过一个隐藏的 iframe 去请求自定义协议(如 my-protocol://detect):
- 如果未安装:浏览器找不到处理程序,无事发生。
- 如果已安装:操作系统会接管这个协议,并尝试唤起对应的客户端,这会导致浏览器窗口失焦。
trick 来了:如何区分“客户端正在后台运行”和“客户端未运行只是装了”?
答案是:看失焦的速度!
如果客户端已经在运行,系统只需执行“聚焦窗口”的操作,速度极快(< 500ms);如果客户端没运行,系统需要先走冷启动流程加载进程,耗时较长(> 500ms)。
2. 状态判断流程图
flowchart TD
A[Web 页面加载完成] --> B[创建隐藏 iframe]
B --> C[尝试跳转 my-protocol://detect]
C --> D{监听 window.blur 或 visibilitychange}
D -->|未触发失焦| E[等待 1500ms 超时]
E --> F[结论: none 未安装]
D -->|触发失焦| G[计算耗时 = 当前时间 - 开始时间]
G --> H{耗时 < 500ms ?}
H -->|是| I[结论: running 已运行]
H -->|否| J[结论: installed 已安装未运行]
3. 核心代码实现 (clientLauncher.js)
这里一定要处理好 setTimeout 的竞态问题,确保 Promise 只 resolve 一次。
const PROTOCOL = 'my-protocol'
const QUICK_BLUR_THRESHOLD = 500 // 响应时间阈值
export function detectClientStatus() {
return new Promise((resolve) => {
let resolved = false
const startTime = Date.now()
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
// 统一的结束函数,防止多次 resolve
const finish = (result) => {
if (resolved) return
resolved = true
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('blur', onBlur)
if (iframe.parentNode) document.body.removeChild(iframe)
resolve(result)
}
const onDetected = () => {
const elapsed = Date.now() - startTime
finish(elapsed < QUICK_BLUR_THRESHOLD ? 'running' : 'installed')
}
const onVisibilityChange = () => { if (document.hidden) onDetected() }
const onBlur = () => onDetected()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('blur', onBlur)
try {
iframe.contentWindow.location.href = `${PROTOCOL}://detect`
} catch (e) { /* 协议未注册报错,忽略 */ }
// 1.5s 内未失焦,视为未安装
const timer = setTimeout(() => finish('none'), 1500)
})
}
4. 带参唤起 & 非阻断 UI
- 为什么用
iframe而不用window.location.href?
因为如果协议解析失败,直接改location会导致当前 Web 页面变成一片空白的错误页! - UI 层面:坚决摒弃阻断式的
Modal弹窗,改用 Ant Design 的notification,允许用户关掉提示继续用网页,15秒后自动消失。
export function launchClient() {
const token = localStorage.getItem('token') || ''
const currentPath = window.location.hash.replace('#', '') || ''
const params = new URLSearchParams()
if (token) params.set('token', token) // 携带登录态
if (currentPath) params.set('redirect', currentPath) // 携带当前路由
const url = `${PROTOCOL}://launch?${params.toString()}`
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
iframe.contentWindow.location.href = url
setTimeout(() => iframe.parentNode && document.body.removeChild(iframe), 3000)
}
三、Electron 端:协议注册与三种场景覆盖
桌面端要做的就两件事:认领协议、解析参数。
1. 协议注册 (electron-builder)
在打包配置中声明协议,安装包会自动往 Windows 的注册表里写东西。
// electron-builder.js
module.exports = {
protocols: [
{ name: "My App Protocol", schemes: ["my-protocol"] }
],
nsis: {
include: './installer.nsh' // 卸载清理用,后面说
}
}
2. 主进程监听处理 (main/index.ts)
- 划重点:必须在
app.ready之前调用app.setAsDefaultProtocolClient。 - 此外,唤起有三种场景,漏掉任何一种都会导致 bug:
const CUSTOM_PROTOCOL = 'my-protocol'
// 注册(注意开发环境和生产环境参数不同)
if (process.defaultApp) {
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
} else {
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL)
}
// 提取解析逻辑
const handleProtocolLaunch = (url: string) => {
if (!win) return
win.isMinimized() && win.restore()
win.focus()
try {
const parsedUrl = new URL(url)
const token = parsedUrl.searchParams.get('token')
const redirect = parsedUrl.searchParams.get('redirect')
if (token) {
// 把 token 和路由发给渲染进程
win.webContents.send('protocol-launch', { token, redirect })
}
} catch (e) {}
}
// 场景1:冷启动(电脑刚开机,第一次点协议唤起)
app.whenReady().then(() => {
createWindow()
if (process.platform === 'win32') {
const protocolUrl = process.argv.find(arg => arg.startsWith(`${CUSTOM_PROTOCOL}://`))
if (protocolUrl) handleProtocolLaunch(protocolUrl)
}
})
// 场景2:热唤起(Windows 下客户端已经打开着)
app.on('second-instance', (_, commandLine) => {
const protocolUrl = commandLine.find(arg => arg.startsWith(`${CUSTOM_PROTOCOL}://`))
if (protocolUrl) handleProtocolLaunch(protocolUrl)
})
// 场景3:macOS 的特殊处理
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolLaunch(url)
})
3. 渲染进程接收实现无缝登录
在 Vue/React 的根组件里监听 IPC,拿到 token 后,如果本地没登录就走 SSO 静默登录,如果已登录就直接 router.replace 跳转。用户体验就是:点了一下浏览器的提示,PC端瞬间闪到眼前,已经是登录状态且在对应页面了。
四、容易被忽视的:卸载清理
很多类似方案在网上能找到,但极少有人提卸载的问题。
- 痛点:
app.setAsDefaultProtocolClient()这个 API 很鸡贼,它不仅会在安装时写入HKCR\my-protocol,在客户端每次运行时,还会往HKCU\Software\Classes\my-protocol写入当前执行路径。
如果用户卸载了客户端,安装包通常只清理HKCR,HKCU里的记录还在!这就导致 Web 端去探测时,操作系统说“我认识这个协议”,然后抛出“找不到应用程序”的系统报错,或者卡死。 - 解决:必须在 NSIS 卸载脚本里双杀:
!macro customUnInstall
; 杀 NSIS 安装时写入的
DeleteRegKey HKCR "my-protocol"
; 杀 app.setAsDefaultProtocolClient() 运行时偷偷写入的(关键!)
DeleteRegKey HKCU "Software\Classes\my-protocol"
DetailPrint "已彻底清理协议注册表"
!macroend
五、🧗 踩坑实录
如果你准备照着这套方案落地,这里可以看下我踩到的坑:
| 坑位描述 | 血泪教训 |
|---|---|
| 协议名拼写不一致 | electron-builder 里配的是 jack-hanger,代码里写的是 jackhanger。导致装了等于没装,怎么都唤不起。解决:全局提取协议名为常量。 |
| 漏掉冷启动场景 | 只写了 second-instance 监听,测试时因为客户端一直开着没发现。发给用户后,用户第一次点击毫无反应。解决:老老实实在 app.whenReady 里解析一遍 process.argv。 |
| 双 Timeout 竞态 | 检测函数里写了两个 setTimeout 互相竞争,导致 Promise 被 resolve 了两次,引起内存泄漏和状态错乱。解决:设立 resolved 哨兵变量,统一走 finish() 函数。 |
| 卸载后误检测(上文提到的) | 只清理了 HKCR,导致卸载后 Web 端依然误判为“已安装”。解决:NSIS 脚本加上清理 HKCU 的逻辑。 |
直接用 location.href 跳转 | 在某些浏览器(如老版 Edge)下,如果协议解析失败,整个 Web 页面会被替换成报错页。解决:坚决使用隐藏 iframe 触发。 |
六、延伸讨论:绕不开的拦截与安全问题
上面这套方案跑通后,体验确实丝滑,但在真实复杂的网络环境下,我们还得面对两个灵魂拷问:
1. 浏览器拦截问题:探测总是不准怎么办?
你会发现,现代浏览器(尤其是 Chrome)对自定义协议的拦截越来越严。
- 首次触发拦截:Chrome 在遇到不认识的
custom-protocol://时,可能会在地址栏底下弹一个条:“请确认是否打开 XXX 应用”,或者直接弹一个系统级警告框。这会严重干扰我们的“失焦计时”判断,导致本来判定为running的状态变成了none或者超时。 - 如何缓解:
- 延迟探测:页面加载后不要立刻测,延迟个 2–3 秒,避免跟页面的其他核心渲染抢焦点。
- 降级处理:接受“检测不准”的现实。如果检测出
none,但在页面上依然放一个肉眼可见的“打开客户端”的按钮。用户手动点击时,浏览器对用户主动触发的协议拦截容忍度会高很多。 - 不要过度依赖黑魔法:如果业务强依赖这种拉起,考虑走 WebSocket 长连接。客户端开机启动一个后台服务监听本地端口,网页直接
fetch('http://127.0.0.1:xxx/ping'),这种基于 HTTP 的探测比自定义协议稳得多(很多大厂云盘就是这么干的)。
2. 安全问题:URL 里明文传 Token 靠谱吗?
我们在唤起时用了 my-protocol://launch?token=xxx,这里埋了两个雷:
- 泄露风险:在 Windows 的某些日志系统、或者使用了历史记录同步的浏览器中,完整的 URL 可能会被明文记录上报。Token 一旦泄露,相当于账号被盗。
- 协议劫持:如果用户电脑上被植入了恶意软件,恶意软件抢先在注册表里注册了
my-protocol,那么网页触发时,实际上是恶意软件接收到了这个 Token。 - 更安全的做法:抛弃直接传 Token 的思路,改用一次性授权码。
- 网页端唤起前,先调后端接口生成一个 5 分钟有效期的短
code。 - 唤起 URL 变成:
my-protocol://launch?code=abc123。 - 客户端拿到
code后,走本地的 HTTP 接口或直接调后端接口,用code换真正的token。 - 即使
code被劫持或记录,因为有效期极短且只能用一次,风险也完全可控。
- 网页端唤起前,先调后端接口生成一个 5 分钟有效期的短
总结
Web 端唤起桌面端并不是什么黑科技,处理好了失焦时间差、隐藏 iframe、三种启动场景和注册表双清,就能打造出一个极其丝滑、无侵入的跨端导流体验。