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:
George·Dong 2025-10-16 21:35:37 +08:00 committed by GitHub
parent f943f05cb1
commit 5eb2772d53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 181 additions and 13 deletions

View File

@ -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%',

View File

@ -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')