告别二次登录!Web端检测并唤起Electron客户端实战

写在前面:审核大大对不起!不小心误删了!麻烦你在审核一遍 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 一次。

javascript 复制代码
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秒后自动消失。
javascript 复制代码
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 的注册表里写东西。

js 复制代码
// 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:
typescript 复制代码
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 写入当前执行路径。
    如果用户卸载了客户端,安装包通常只清理 HKCRHKCU 里的记录还在!这就导致 Web 端去探测时,操作系统说"我认识这个协议",然后抛出"找不到应用程序"的系统报错,或者卡死。
  • 解决:必须在 NSIS 卸载脚本里双杀:
nsi 复制代码
!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 的思路,改用一次性授权码
    1. 网页端唤起前,先调后端接口生成一个 5 分钟有效期的短 code
    2. 唤起 URL 变成:my-protocol://launch?code=abc123
    3. 客户端拿到 code 后,走本地的 HTTP 接口或直接调后端接口,用 code 换真正的 token
    4. 即使 code 被劫持或记录,因为有效期极短且只能用一次,风险也完全可控。

总结

Web 端唤起桌面端并不是什么黑科技,处理好了失焦时间差、隐藏 iframe、三种启动场景和注册表双清,就能打造出一个极其丝滑、无侵入的跨端导流体验。


相关推荐
runnerdancer15 小时前
LLM是怎么处理messages数组的,提示词缓存又是什么
前端·agent
陈随易16 小时前
VSCode的Copilot扩展支持接入DeepSeek,Kimi了!
前端·后端·程序员
我不是外星人18 小时前
有了 Harness Engineering ,真的还需要研发工程师吗?
前端·后端·ai编程
IT_陈寒20 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
Jackson__21 小时前
分享一个横向滚动案例,带悬停暂停,通用性很强
前端
MariaH1 天前
git rebase的使用
前端
_柳青杨1 天前
深入理解 JavaScript 事件循环
前端·javascript
阡陌Jony1 天前
关于前端性能优化的一些问题:
前端
用户600071819101 天前
【翻译】简化 TSRX
前端
IT乐手1 天前
佛德角逼平西班牙,国足还有啥借口?
前端