问题描述

在开发 Tauri 桌面应用时,我们遇到了一个常见问题:在嵌入的 Webview 中点击网页链接时没有响应。具体表现是:

  • Webview 可以正常加载百度首页
  • 点击搜索结果或其他链接时,页面没有任何反应
  • 控制台出现权限错误提示

问题背景

我们的应用使用 Tauri 2.0 框架,在一个独立的窗口中嵌入了一个 Webview 来显示百度搜索页面。代码实现如下:

import { Webview } from '@tauri-apps/api/webview'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { openUrl } from '@tauri-apps/plugin-opener'

const webviewContainer = ref<HTMLElement | null>(null)
const externalWebview = ref<Webview | null>(null)
const webviewLabel = 'baidu-webview'
const currentWindow = getCurrentWebviewWindow()

const initWebview = async () => {
  await nextTick()

  if (!webviewContainer.value) {
    console.error('webviewContainer 未找到')
    return
  }

  const windowInstance = getCurrentWebviewWindow()

  try {
    const existing = await Webview.getByLabel(webviewLabel)
    if (existing) {
      await existing.close()
    }
  } catch (error) {
    console.log('没有找到已存在的 webview')
  }

  try {
    const rect = webviewContainer.value.getBoundingClientRect()

    const newWebview = new Webview(windowInstance, webviewLabel, {
      url: 'https://www.baidu.com',
      x: rect.left,
      y: rect.top,
      width: rect.width,
      height: rect.height,
      focus: true,
      dragDropEnabled: true
    })

    externalWebview.value = newWebview

    newWebview.once('tauri://created', async () => {
      console.log('Webview 创建成功')
      await updateWebviewBounds()
    })

    newWebview.once('tauri://error', (error) => {
      console.error('Webview 创建失败:', error)
      externalWebview.value = null
    })
  } catch (error) {
    console.error('创建 webview 失败:', error)
    console.log('尝试在系统浏览器中打开百度')
    try {
      await openUrl('https://www.baidu.com')
    } catch (openError) {
      console.error('在浏览器中打开失败:', openError)
    }
  }
}

错误信息

点击链接时,控制台出现以下错误:

Uncaught (in promise) shell.open not allowed on window "baidu", webview "baidu-webview", URL: `https://www.baidu.com/`

allowed on: [windows: "*", URL: local], [windows: "*", URL: local]

referenced by: capability: default-capability, permission: allow-open || capability: default-capability, permission: allow-open

问题分析

初步排查

首先,我尝试添加导航事件监听器来拦截和处理链接点击:

const handleNavigation = async (event: any) => {
  const url = event.payload?.url || event.url
  if (!url) return

  console.log('导航事件:', url)

  try {
    const currentUrl = new URL(url)
    const baiduUrl = new URL('https://www.baidu.com')

    if (currentUrl.hostname !== baiduUrl.hostname) {
      event.preventDefault()
      await openUrl(url)
    }
  } catch (error) {
    console.error('处理导航失败:', error)
  }
}

newWebview.once('tauri://created', async () => {
  console.log('Webview 创建成功')
  await updateWebviewBounds()

  try {
    navigationUnlisten = await newWebview.onNavigationRequested(handleNavigation)
    console.log('导航事件监听器已注册')
  } catch (error) {
    console.error('注册导航监听器失败:', error)
  }
})

但是这个方案导致了同样的权限错误。

深入分析

仔细观察错误信息,我发现了关键线索:

  1. 错误提示 shell.open not allowed - 这表明是 shell 插件的权限问题
  2. allowed on: [windows: "*", URL: local] - 只允许 local URL
  3. 但代码中使用的是 @tauri-apps/plugin-opener 的 openUrl 函数

这说明问题不是 shell:open 的权限,而是 opener 插件的权限配置不完整。

检查权限配置

查看 src-tauri/capabilities/default.json 文件:

{
  "permissions": [
    // ... 其他权限
    "opener:default",
    "opener:allow-reveal-item-in-dir",
    "opener:allow-open-path",
    // 缺少 opener:allow-open-url
    "mic-recorder:default",
    // ...
  ]
}

发现问题所在:配置中只有 opener:allow-open-path(用于打开本地文件路径),缺少 opener:allow-open-url(用于打开外部 URL)。

解决方案

1. 添加缺失的权限

在 src-tauri/capabilities/default.json 中添加 opener:allow-open-url 权限:

{
  "permissions": [
    // ... 其他权限
    "opener:default",
    "opener:allow-reveal-item-in-dir",
    "opener:allow-open-path",
    "opener:allow-open-url",  // 新增这一行
    "mic-recorder:default",
    // ...
  ]
}

2. 移除导航拦截代码

由于我们已经添加了正确的权限,不再需要手动拦截导航事件。移除之前添加的导航拦截代码,让 Webview 按照默认行为工作。

3. 重新编译

修改权限配置后,需要重新编译 Tauri 应用:

# 开发环境
pnpm dev

# 或者
cd src-tauri
cargo tauri dev

最终代码

优化后的代码更加简洁,移除了不必要的导航拦截逻辑:

import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Webview } from '@tauri-apps/api/webview'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { openUrl } from '@tauri-apps/plugin-opener'

const webviewContainer = ref<HTMLElement | null>(null)
const externalWebview = ref<Webview | null>(null)
const webviewLabel = 'baidu-webview'
const currentWindow = getCurrentWebviewWindow()

let containerResizeObserver: ResizeObserver | null = null
let windowResizeListener: (() => void) | null = null

const updateWebviewBounds = async () => {
  if (!webviewContainer.value || !externalWebview.value) return

  try {
    const rect = webviewContainer.value.getBoundingClientRect()
    await externalWebview.value.setPosition(rect.left, rect.top)
    await externalWebview.value.setSize(rect.width, rect.height)
  } catch (error) {
    console.error('更新 webview 边界失败:', error)
  }
}

const initWebview = async () => {
  await nextTick()

  if (!webviewContainer.value) {
    console.error('webviewContainer 未找到')
    return
  }

  const windowInstance = getCurrentWebviewWindow()

  try {
    const existing = await Webview.getByLabel(webviewLabel)
    if (existing) {
      await existing.close()
    }
  } catch (error) {
    console.log('没有找到已存在的 webview')
  }

  try {
    const rect = webviewContainer.value.getBoundingClientRect()

    const newWebview = new Webview(windowInstance, webviewLabel, {
      url: 'https://www.baidu.com',
      x: rect.left,
      y: rect.top,
      width: rect.width,
      height: rect.height,
      focus: true,
      dragDropEnabled: true
    })

    externalWebview.value = newWebview

    containerResizeObserver = new ResizeObserver(() => {
      updateWebviewBounds()
    })
    containerResizeObserver.observe(webviewContainer.value)

    windowResizeListener = () => {
      updateWebviewBounds()
    }
    window.addEventListener('resize', windowResizeListener, { passive: true })

    newWebview.once('tauri://created', async () => {
      console.log('Webview 创建成功')
      await updateWebviewBounds()
    })

    newWebview.once('tauri://error', (error) => {
      console.error('Webview 创建失败:', error)
      externalWebview.value = null
    })
  } catch (error) {
    console.error('创建 webview 失败:', error)
    console.log('尝试在系统浏览器中打开百度')
    try {
      await openUrl('https://www.baidu.com')
    } catch (openError) {
      console.error('在浏览器中打开失败:', openError)
    }
  }
}

onMounted(async () => {
  await initWebview()
})

onUnmounted(async () => {
  if (containerResizeObserver) {
    containerResizeObserver.disconnect()
  }
  if (windowResizeListener) {
    window.removeEventListener('resize', windowResizeListener)
  }
  if (externalWebview.value) {
    try {
      await externalWebview.value.close()
    } catch (error) {
      console.error('关闭 webview 失败:', error)
    }
  }
})

关键知识点

Tauri 权限系统

Tauri 2.0 使用基于 Capability 的权限系统,每个功能都需要在 capabilities/*.json 文件中明确授权:

  • opener:allow-open-path: 用于打开本地文件路径
  • opener:allow-open-url: 用于打开外部 URL
  • shell:allow-open: 用于执行系统命令

Webview 导航行为

在 Tauri 中,Webview 的导航行为默认是自由的,不需要手动拦截。只有在需要特殊处理(如阻止某些导航、重定向到外部浏览器等)时,才需要使用 onNavigationRequested 事件。

常见错误信息解读

shell.open not allowed on window "xxx", webview "xxx", URL: `https://xxx/`
allowed on: [windows: "*", URL: local]

这个错误信息的含义:

  • shell.open not allowed: shell 插件的 open 命令不被允许
  • URL: local: 只允许 local 类型的 URL
  • 需要检查对应的权限配置是否完整

总结

  1. 问题根源: 使用了 @tauri-apps/plugin-opener 的 openUrl 函数,但权限配置中缺少 opener:allow-open-url

  2. 解决方法: 在 capabilities/default.json 中添加 opener:allow-open-url 权限

  3. 最佳实践:

    • 仔细阅读错误信息,理解权限限制
    • 区分不同插件的权限需求(shell vs opener)
    • 优先使用默认行为,避免过度拦截导航事件
    • 修改权限配置后记得重新编译应用
  4. 调试技巧:

    • 查看控制台的完整错误信息
    • 检查 capabilities/*.json 文件的权限配置
    • 确认使用的 API 对应的权限要求
    • 重新编译以应用权限更改

通过这次排查,我们不仅解决了当前问题,还深入理解了 Tauri 的权限系统,为后续开发积累了宝贵经验。

参考资料

更多推荐