mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 13:59:28 +08:00
fix(minapps): can't open links in external broswer when using tab navigation (#10669)
* fix(minapps): can't open links in external broswer when using tab navigation * fix(minapps): stabilize webview navigation and add logging * fix(minapps): debounce nav updates and robust webview attach
This commit is contained in:
parent
5ce9209334
commit
26c7dd9976
@ -25,7 +25,7 @@ const WebviewContainer = memo(
|
|||||||
onNavigateCallback: (appid: string, url: string) => void
|
onNavigateCallback: (appid: string, url: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
const webviewRef = useRef<WebviewTag | null>(null)
|
const webviewRef = useRef<WebviewTag | null>(null)
|
||||||
const { enableSpellCheck } = useSettings()
|
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
|
||||||
|
|
||||||
const setRef = (appid: string) => {
|
const setRef = (appid: string) => {
|
||||||
onSetRefCallback(appid, null)
|
onSetRefCallback(appid, null)
|
||||||
@ -76,6 +76,8 @@ const WebviewContainer = memo(
|
|||||||
const webviewId = webviewRef.current?.getWebContentsId()
|
const webviewId = webviewRef.current?.getWebContentsId()
|
||||||
if (webviewId) {
|
if (webviewId) {
|
||||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||||
|
// Set link opening behavior for this webview
|
||||||
|
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +106,22 @@ const WebviewContainer = memo(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appid, url])
|
}, [appid, url])
|
||||||
|
|
||||||
|
// Update webview settings when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webviewRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webviewId = webviewRef.current.getWebContentsId()
|
||||||
|
if (webviewId) {
|
||||||
|
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||||
|
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// WebView may not be ready yet, settings will be applied in dom-ready event
|
||||||
|
logger.debug(`WebView ${appid} not ready for settings update`)
|
||||||
|
}
|
||||||
|
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
|
||||||
|
|
||||||
const WebviewStyle: React.CSSProperties = {
|
const WebviewStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
ReloadOutlined
|
ReloadOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
import { isDev } from '@renderer/config/constant'
|
import { isDev } from '@renderer/config/constant'
|
||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
@ -17,11 +18,21 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
|||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { WebviewTag } from 'electron'
|
import { WebviewTag } from 'electron'
|
||||||
import { FC, useCallback, useState } from 'react'
|
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('MinimalToolbar')
|
||||||
|
|
||||||
|
// Constants for timing delays
|
||||||
|
const WEBVIEW_CHECK_INITIAL_MS = 100 // Initial check interval
|
||||||
|
const WEBVIEW_CHECK_MAX_MS = 1000 // Maximum check interval (1 second)
|
||||||
|
const WEBVIEW_CHECK_MULTIPLIER = 2 // Exponential backoff multiplier
|
||||||
|
const WEBVIEW_CHECK_MAX_ATTEMPTS = 30 // Stop after ~30 seconds total
|
||||||
|
const NAVIGATION_UPDATE_DELAY_MS = 50
|
||||||
|
const NAVIGATION_COMPLETE_DELAY_MS = 100
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
app: MinAppType
|
app: MinAppType
|
||||||
webviewRef: React.RefObject<WebviewTag | null>
|
webviewRef: React.RefObject<WebviewTag | null>
|
||||||
@ -42,27 +53,166 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
|
|||||||
const isPinned = pinned.some((item) => item.id === app.id)
|
const isPinned = pinned.some((item) => item.id === app.id)
|
||||||
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||||
|
|
||||||
|
// Ref to track navigation update timeout
|
||||||
|
const navigationUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
// Update navigation state
|
// Update navigation state
|
||||||
const updateNavigationState = useCallback(() => {
|
const updateNavigationState = useCallback(() => {
|
||||||
if (webviewRef.current) {
|
if (webviewRef.current) {
|
||||||
setCanGoBack(webviewRef.current.canGoBack())
|
try {
|
||||||
setCanGoForward(webviewRef.current.canGoForward())
|
setCanGoBack(webviewRef.current.canGoBack())
|
||||||
|
setCanGoForward(webviewRef.current.canGoForward())
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('WebView not ready for navigation state update', { appId: app.id })
|
||||||
|
setCanGoBack(false)
|
||||||
|
setCanGoForward(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCanGoBack(false)
|
||||||
|
setCanGoForward(false)
|
||||||
}
|
}
|
||||||
}, [webviewRef])
|
}, [app.id, webviewRef])
|
||||||
|
|
||||||
|
// Schedule navigation state update with debouncing
|
||||||
|
const scheduleNavigationUpdate = useCallback(
|
||||||
|
(delay: number) => {
|
||||||
|
if (navigationUpdateTimeoutRef.current) {
|
||||||
|
clearTimeout(navigationUpdateTimeoutRef.current)
|
||||||
|
}
|
||||||
|
navigationUpdateTimeoutRef.current = setTimeout(() => {
|
||||||
|
updateNavigationState()
|
||||||
|
navigationUpdateTimeoutRef.current = null
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
[updateNavigationState]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup navigation timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (navigationUpdateTimeoutRef.current) {
|
||||||
|
clearTimeout(navigationUpdateTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Monitor webviewRef changes and update navigation state
|
||||||
|
useEffect(() => {
|
||||||
|
let checkTimeout: NodeJS.Timeout | null = null
|
||||||
|
let navigationListener: (() => void) | null = null
|
||||||
|
let listenersAttached = false
|
||||||
|
let currentInterval = WEBVIEW_CHECK_INITIAL_MS
|
||||||
|
let attemptCount = 0
|
||||||
|
|
||||||
|
const attachListeners = () => {
|
||||||
|
if (webviewRef.current && !listenersAttached) {
|
||||||
|
// Update state immediately
|
||||||
|
updateNavigationState()
|
||||||
|
|
||||||
|
// Add navigation event listeners
|
||||||
|
const handleNavigation = () => {
|
||||||
|
scheduleNavigationUpdate(NAVIGATION_UPDATE_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
webviewRef.current.addEventListener('did-navigate', handleNavigation)
|
||||||
|
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigation)
|
||||||
|
listenersAttached = true
|
||||||
|
|
||||||
|
navigationListener = () => {
|
||||||
|
if (webviewRef.current) {
|
||||||
|
webviewRef.current.removeEventListener('did-navigate', handleNavigation)
|
||||||
|
webviewRef.current.removeEventListener('did-navigate-in-page', handleNavigation)
|
||||||
|
}
|
||||||
|
listenersAttached = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkTimeout) {
|
||||||
|
clearTimeout(checkTimeout)
|
||||||
|
checkTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Navigation listeners attached', { appId: app.id, attempts: attemptCount })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleCheck = () => {
|
||||||
|
checkTimeout = setTimeout(() => {
|
||||||
|
// Use requestAnimationFrame to avoid blocking the main thread
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
attemptCount++
|
||||||
|
if (!attachListeners()) {
|
||||||
|
// Stop checking after max attempts to prevent infinite loops
|
||||||
|
if (attemptCount >= WEBVIEW_CHECK_MAX_ATTEMPTS) {
|
||||||
|
logger.warn('WebView attachment timeout', {
|
||||||
|
appId: app.id,
|
||||||
|
attempts: attemptCount,
|
||||||
|
totalTimeMs: currentInterval * attemptCount
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: double the interval up to the maximum
|
||||||
|
currentInterval = Math.min(currentInterval * WEBVIEW_CHECK_MULTIPLIER, WEBVIEW_CHECK_MAX_MS)
|
||||||
|
|
||||||
|
// Log only on first few attempts or when interval changes significantly
|
||||||
|
if (attemptCount <= 3 || attemptCount % 10 === 0) {
|
||||||
|
logger.debug('WebView not ready, scheduling next check', {
|
||||||
|
appId: app.id,
|
||||||
|
nextCheckMs: currentInterval,
|
||||||
|
attempt: attemptCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleCheck()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, currentInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for webview attachment
|
||||||
|
if (!webviewRef.current) {
|
||||||
|
scheduleCheck()
|
||||||
|
} else {
|
||||||
|
attachListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (checkTimeout) clearTimeout(checkTimeout)
|
||||||
|
if (navigationListener) navigationListener()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [app.id, updateNavigationState, scheduleNavigationUpdate]) // webviewRef excluded as it's a ref object
|
||||||
|
|
||||||
const handleGoBack = useCallback(() => {
|
const handleGoBack = useCallback(() => {
|
||||||
if (webviewRef.current && webviewRef.current.canGoBack()) {
|
if (webviewRef.current) {
|
||||||
webviewRef.current.goBack()
|
try {
|
||||||
updateNavigationState()
|
if (webviewRef.current.canGoBack()) {
|
||||||
|
webviewRef.current.goBack()
|
||||||
|
// Delay update to ensure navigation completes
|
||||||
|
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goBack' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [webviewRef, updateNavigationState])
|
}, [app.id, webviewRef, scheduleNavigationUpdate])
|
||||||
|
|
||||||
const handleGoForward = useCallback(() => {
|
const handleGoForward = useCallback(() => {
|
||||||
if (webviewRef.current && webviewRef.current.canGoForward()) {
|
if (webviewRef.current) {
|
||||||
webviewRef.current.goForward()
|
try {
|
||||||
updateNavigationState()
|
if (webviewRef.current.canGoForward()) {
|
||||||
|
webviewRef.current.goForward()
|
||||||
|
// Delay update to ensure navigation completes
|
||||||
|
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goForward' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [webviewRef, updateNavigationState])
|
}, [app.id, webviewRef, scheduleNavigationUpdate])
|
||||||
|
|
||||||
const handleMinimize = useCallback(() => {
|
const handleMinimize = useCallback(() => {
|
||||||
navigate('/apps')
|
navigate('/apps')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user