mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +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
f943f05cb1
commit
5eb2772d53
@ -25,7 +25,7 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback: (appid: string, url: string) => void
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const { enableSpellCheck } = useSettings()
|
||||
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
@ -76,6 +76,8 @@ const WebviewContainer = memo(
|
||||
const webviewId = webviewRef.current?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
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
|
||||
}, [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 = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
PushpinOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { isDev } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
@ -17,11 +18,21 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 {
|
||||
app: MinAppType
|
||||
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 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
|
||||
const updateNavigationState = useCallback(() => {
|
||||
if (webviewRef.current) {
|
||||
setCanGoBack(webviewRef.current.canGoBack())
|
||||
setCanGoForward(webviewRef.current.canGoForward())
|
||||
try {
|
||||
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(() => {
|
||||
if (webviewRef.current && webviewRef.current.canGoBack()) {
|
||||
webviewRef.current.goBack()
|
||||
updateNavigationState()
|
||||
if (webviewRef.current) {
|
||||
try {
|
||||
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(() => {
|
||||
if (webviewRef.current && webviewRef.current.canGoForward()) {
|
||||
webviewRef.current.goForward()
|
||||
updateNavigationState()
|
||||
if (webviewRef.current) {
|
||||
try {
|
||||
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(() => {
|
||||
navigate('/apps')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user