diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index dcae087f6d..545772ef08 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -25,7 +25,7 @@ const WebviewContainer = memo( onNavigateCallback: (appid: string, url: string) => void }) => { const webviewRef = useRef(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%', diff --git a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx index dcbe7adeff..ef89fe6409 100644 --- a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx +++ b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx @@ -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 @@ -42,27 +53,166 @@ const MinimalToolbar: FC = ({ 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(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')