告别二次登录!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、三种启动场景和注册表双清,就能打造出一个极其丝滑、无侵入的跨端导流体验。


相关推荐
岁月宁静2 小时前
都知道AI大模型能生成文本内容,那你知道大模型是怎样生成文本的吗?
前端·vue.js·人工智能
花间相见3 小时前
【终端效率工具01】—— Yazi:Rust 编写的现代化终端文件管理器,告别繁琐操作
前端·ide·git·rust·极限编程
|晴 天|3 小时前
我如何用Vue 3打造一个现代化个人博客系统(性能提升52%)
前端·javascript·vue.js
风止何安啊3 小时前
网页都知道要双向握手才加载!从 URL 到页面渲染,单向喜欢连 DNS 都解析不通
前端·javascript·面试
太极OS3 小时前
给 AI Skill 做 CI/CD:GitHub + ClawHub + Xiaping 同步发布实战
前端
你_好3 小时前
Chrome 内置了 AI 工具协议?WebMCP 抢先体验 + 开源 DevTools 全解析
前端·mcp
GISer_Jing3 小时前
LangChain.js + LangGraph.js 前端AI开发实战指南
前端·javascript·langchain
正在发育ing__3 小时前
从源码看vue的key和状态错乱的patch
前端
黄林晴4 小时前
第一次听到 Tauri 这个词,去学习一下
前端