refactor(miniapp): 适配顶部状态栏 (#9695)

* feat(minapp): add top-navbar fixed toolbar and layout adjustments

* refactor(minapps): optimize toolbar

* fix(minapps): hide redundant components

* feat(minapp): improve webview load handling and popup visibility

* feat(minapps): improve WebView load handling and clean up launchpad

* feat(minapp): 实现活跃小程序数量限制与关闭缓存清理

* fix(minapp): 修复WebView高度不正确的问题

* fix(minapp): show popup only for left navbar mode

* feat(minapps): add full-screen loading mask for webview

* fix: lint error

* feat(minapp): fix drawer sizing and layout when side navbar present

* refactor(minapp): 移除固定工具栏组件,优化弹窗容器布局

* feat(minapps): memoize app lookup to avoid unnecessary recompute

* chore(minapps): optimize comments

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(renderer): remove stray blank line in MinAppFullPageView

* refactor(minapps): remove top navbar opened minapps component

* refactor(tab): remove unused TopNavbarOpenedMinappTabs import

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
George·Dong 2025-09-04 23:59:54 +08:00 committed by GitHub
parent b647017c43
commit 935189f3f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 749 additions and 189 deletions

View File

@ -14,6 +14,7 @@ import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppPage from './pages/minapps/MinAppPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
@ -34,6 +35,7 @@ const Router: FC = () => {
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps/:appId" element={<MinAppPage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />

View File

@ -13,6 +13,7 @@ import { Dropdown } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface Props {
@ -30,6 +31,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
const dispatch = useDispatch()
const navigate = useNavigate()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const isActive = minappShow && currentMinappId === app.id
@ -37,7 +39,13 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { isTopNavbar } = useNavbarPosition()
const handleClick = () => {
openMinappKeepAlive(app)
if (isTopNavbar) {
// 顶部导航栏:导航到小程序页面
navigate(`/apps/${app.id}`)
} else {
// 侧边导航栏:保持原有弹窗行为
openMinappKeepAlive(app)
}
onClick?.()
}

View File

@ -24,6 +24,7 @@ import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useMemo, useRef, useState } from 'react'
@ -162,8 +163,7 @@ const MinappPopupContainer: React.FC = () => {
/** store the webview refs, one of the key to make them keepalive */
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
/** indicate whether the webview has loaded */
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
/** Note: WebView loaded states now managed globally via webviewStateManager */
/** whether the minapps open link external is enabled */
const { minappsOpenLinkExternal } = useSettings()
@ -185,7 +185,7 @@ const MinappPopupContainer: React.FC = () => {
setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) {
if (getWebviewLoaded(currentMinappId)) {
setIsReady(true)
/** the case that open the minapp from sidebar */
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
@ -216,17 +216,21 @@ const MinappPopupContainer: React.FC = () => {
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
})
//delete the extra webviewLoadedRefs
webviewLoadedRefs.current.forEach((_, appid) => {
if (!webviewRefs.current.has(appid)) {
webviewLoadedRefs.current.delete(appid)
} else if (appid === currentMinappId) {
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
// Set external link behavior for current minapp
if (currentMinappId) {
const webviewElement = webviewRefs.current.get(currentMinappId)
if (webviewElement) {
try {
const webviewId = webviewElement.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView not ready yet, will be set when it's loaded
logger.debug(`WebView ${currentMinappId} not ready for getWebContentsId()`)
}
}
})
}
}, [currentMinappId, minappsOpenLinkExternal])
/** only the keepalive minapp can be minimized */
@ -255,15 +259,17 @@ const MinappPopupContainer: React.FC = () => {
/** get the current app info with extra info */
let currentAppInfo: AppInfo | null = null
if (currentMinappId) {
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
const currentApp = combinedApps.find((item) => item.id === currentMinappId)
if (currentApp) {
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
}
}
/** will close the popup and delete the webview */
const handlePopupClose = async (appid: string) => {
setIsPopupShow(false)
await delay(0.3)
webviewLoadedRefs.current.delete(appid)
clearWebviewState(appid)
closeMinapp(appid)
}
@ -292,10 +298,17 @@ const MinappPopupContainer: React.FC = () => {
/** the callback function to set the webviews loaded indicator */
const handleWebviewLoaded = (appid: string) => {
webviewLoadedRefs.current.set(appid, true)
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
setWebviewLoaded(appid, true)
const webviewElement = webviewRefs.current.get(appid)
if (webviewElement) {
try {
const webviewId = webviewElement.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for getWebContentsId() in handleWebviewLoaded`)
}
}
if (appid == currentMinappId) {
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
@ -352,16 +365,28 @@ const MinappPopupContainer: React.FC = () => {
/** navigate back in webview history */
const handleGoBack = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoBack()) {
webview.goBack()
if (webview) {
try {
if (webview.canGoBack()) {
webview.goBack()
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for goBack()`)
}
}
}
/** navigate forward in webview history */
const handleGoForward = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoForward()) {
webview.goForward()
if (webview) {
try {
if (webview.canGoForward()) {
webview.goForward()
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for goForward()`)
}
}
}
@ -409,7 +434,7 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavbar={isTopNavbar}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
@ -498,19 +523,23 @@ const MinappPopupContainer: React.FC = () => {
return (
<Drawer
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
title={isTopNavbar ? null : <Title appInfo={currentAppInfo} url={currentUrl} />}
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
mask={false}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'}
maskClosable={false}
closeIcon={null}
style={{
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
backgroundColor: window.root.style.background
styles={{
wrapper: {
position: 'fixed',
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0,
backgroundColor: window.root.style.background
}
}}>
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
@ -566,7 +595,7 @@ const TitleTextTooltip = styled.span`
}
`
const ButtonsGroup = styled.div`
const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
display: flex;
flex-direction: row;
align-items: center;

View File

@ -1,11 +1,14 @@
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
const TopViewMinappContainer = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
const { isLeftNavbar } = useNavbarPosition()
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
return <>{isCreate && <MinappPopupContainer />}</>
// Only show popup container in sidebar mode (left navbar), not in tab mode (top navbar)
return <>{isCreate && isLeftNavbar && <MinappPopupContainer />}</>
}
export default TopViewMinappContainer

View File

@ -1,7 +1,10 @@
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { loggerService } from '@logger'
import { useSettings } from '@renderer/hooks/useSettings'
import { WebviewTag } from 'electron'
import { memo, useEffect, useRef } from 'react'
const logger = loggerService.withContext('WebviewContainer')
/**
* WebviewContainer is a component that renders a webview element.
* It is used in the MinAppPopupContainer component.
@ -23,7 +26,6 @@ const WebviewContainer = memo(
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck } = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@ -41,8 +43,29 @@ const WebviewContainer = memo(
useEffect(() => {
if (!webviewRef.current) return
let loadCallbackFired = false
const handleLoaded = () => {
onLoadedCallback(appid)
logger.debug(`WebView did-finish-load for app: ${appid}`)
// Only fire callback once per load cycle
if (!loadCallbackFired) {
loadCallbackFired = true
// Small delay to ensure content is actually visible
setTimeout(() => {
logger.debug(`Calling onLoadedCallback for app: ${appid}`)
onLoadedCallback(appid)
}, 100)
}
}
// Additional callback for when page is ready to show
const handleReadyToShow = () => {
logger.debug(`WebView ready-to-show for app: ${appid}`)
if (!loadCallbackFired) {
loadCallbackFired = true
logger.debug(`Calling onLoadedCallback from ready-to-show for app: ${appid}`)
onLoadedCallback(appid)
}
}
const handleNavigate = (event: any) => {
@ -56,16 +79,25 @@ const WebviewContainer = memo(
}
}
const handleStartLoading = () => {
// Reset callback flag when starting a new load
loadCallbackFired = false
}
webviewRef.current.addEventListener('did-start-loading', handleStartLoading)
webviewRef.current.addEventListener('dom-ready', handleDomReady)
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
webviewRef.current.addEventListener('ready-to-show', handleReadyToShow)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
// we set the url when the webview is ready
webviewRef.current.src = url
return () => {
webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading)
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow)
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
}
// because the appid and url are enough, no need to add onLoadedCallback
@ -73,8 +105,8 @@ const WebviewContainer = memo(
}, [appid, url])
const WebviewStyle: React.CSSProperties = {
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
height: 'calc(100vh - var(--navbar-height))',
width: '100%',
height: '100%',
backgroundColor: 'var(--color-background)',
display: 'inline-flex'
}

View File

@ -1,11 +1,12 @@
import { PlusOutlined } from '@ant-design/icons'
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
import tabsService from '@renderer/services/TabsService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
@ -37,11 +38,22 @@ import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import MinAppIcon from '../Icons/MinAppIcon'
interface TabsContainerProps {
children: React.ReactNode
}
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
// Check if it's a minapp tab (format: apps:appId)
if (tabId.startsWith('apps:')) {
const appId = tabId.replace('apps:', '')
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
if (app) {
return <MinAppIcon size={14} app={app} />
}
}
switch (tabId) {
case 'home':
return <Home size={14} />
@ -82,6 +94,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const isFullscreen = useFullscreen()
const { settedTheme, toggleTheme } = useTheme()
const { hideMinappPopup } = useMinappPopup()
const { minapps } = useMinapps()
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const [canScroll, setCanScroll] = useState(false)
@ -89,9 +102,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const getTabId = (path: string): string => {
if (path === '/') return 'home'
const segments = path.split('/')
// Handle minapp paths: /apps/appId -> apps:appId
if (segments[1] === 'apps' && segments[2]) {
return `apps:${segments[2]}`
}
return segments[1] // 获取第一个路径段作为 id
}
const getTabTitle = (tabId: string): string => {
// Check if it's a minapp tab
if (tabId.startsWith('apps:')) {
const appId = tabId.replace('apps:', '')
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
return app ? app.name : 'MinApp'
}
return getTitleLabel(tabId)
}
const shouldCreateTab = (path: string) => {
if (path === '/') return false
if (path === '/settings') return false
@ -196,8 +223,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
@ -224,7 +251,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
</AddTabButton>
</TabsArea>
<RightButtonsContainer>
<TopNavbarOpenedMinappTabs />
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
mouseEnterDelay={0.8}

View File

@ -6,107 +6,13 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { MinAppType } from '@renderer/types'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { FC, useEffect, useState } from 'react'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { DraggableList } from '../DraggableList'
import MinAppIcon from '../Icons/MinAppIcon'
/** Tabs of opened minapps in top navbar */
export const TopNavbarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings()
const { theme } = useTheme()
const { t } = useTranslation()
const [keepAliveMinapps, setKeepAliveMinapps] = useState(openedKeepAliveMinapps)
useEffect(() => {
const timer = setTimeout(() => setKeepAliveMinapps(openedKeepAliveMinapps), 300)
return () => clearTimeout(timer)
}, [openedKeepAliveMinapps])
// animation for minapp switch indicator
useEffect(() => {
const iconDefaultWidth = 30 // 22px icon + 8px gap
const iconDefaultOffset = 10 // initial offset
const container = document.querySelector('.TopNavContainer') as HTMLElement
const activeIcon = document.querySelector('.TopNavContainer .opened-active') as HTMLElement
let indicatorLeft = 0,
indicatorBottom = 0
if (minappShow && activeIcon && container) {
indicatorLeft = activeIcon.offsetLeft + activeIcon.offsetWidth / 2 - 4 // 4 is half of the indicator's width (8px)
indicatorBottom = 0
} else {
indicatorLeft =
((keepAliveMinapps.length > 0 ? keepAliveMinapps.length : 1) / 2) * iconDefaultWidth + iconDefaultOffset - 4
indicatorBottom = -50
}
container?.style.setProperty('--indicator-left', `${indicatorLeft}px`)
container?.style.setProperty('--indicator-bottom', `${indicatorBottom}px`)
}, [currentMinappId, keepAliveMinapps, minappShow])
const handleOnClick = (app: MinAppType) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
openMinappKeepAlive(app)
}
}
// 检查是否需要显示已打开小程序组件
const isShowOpened = showOpenedMinappsInSidebar && keepAliveMinapps.length > 0
// 如果不需要显示,返回空容器
if (!isShowOpened) return null
return (
<TopNavContainer
className="TopNavContainer"
style={{ backgroundColor: keepAliveMinapps.length > 0 ? 'var(--color-list-item)' : 'transparent' }}>
<TopNavMenus>
{keepAliveMinapps.map((app) => {
const menuItems: MenuProps['items'] = [
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => {
closeMinapp(app.id)
}
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => {
closeAllMinapps()
}
}
]
const isActive = minappShow && currentMinappId === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="bottom">
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<TopNavItemContainer
onClick={() => handleOnClick(app)}
theme={theme}
className={`${isActive ? 'opened-active' : ''}`}>
<TopNavIcon theme={theme}>
<MinAppIcon size={22} app={app} style={{ border: 'none', padding: 0 }} />
</TopNavIcon>
</TopNavItemContainer>
</Dropdown>
</Tooltip>
)
})}
</TopNavMenus>
</TopNavContainer>
)
}
/** Tabs of opened minapps in sidebar */
export const SidebarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
@ -116,7 +22,7 @@ export const SidebarOpenedMinappTabs: FC = () => {
const { t } = useTranslation()
const { isLeftNavbar } = useNavbarPosition()
const handleOnClick = (app) => {
const handleOnClick = (app: MinAppType) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
@ -329,50 +235,3 @@ const TabsWrapper = styled.div`
border-radius: 20px;
overflow: hidden;
`
const TopNavContainer = styled.div`
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
background-color: var(--color-list-item);
border-radius: 20px;
margin: 0 5px;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
left: var(--indicator-left, 0);
bottom: var(--indicator-bottom, 0);
width: 8px;
height: 4px;
background-color: var(--color-primary);
transition:
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
bottom 0.3s ease-in-out;
border-radius: 2px;
}
`
const TopNavMenus = styled.div`
display: flex;
align-items: center;
gap: 8px;
height: 100%;
`
const TopNavIcon = styled(Icon)`
width: 22px;
height: 22px;
`
const TopNavItemContainer = styled.div`
display: flex;
transition: border 0.2s ease;
border-radius: 18px;
cursor: pointer;
border-radius: 50%;
padding: 2px;
`

View File

@ -1,6 +1,7 @@
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
import TabsService from '@renderer/services/TabsService'
import { useAppDispatch } from '@renderer/store'
import {
setCurrentMinappId,
@ -9,6 +10,7 @@ import {
setOpenedOneOffMinapp
} from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
import { LRUCache } from 'lru-cache'
import { useCallback } from 'react'
@ -36,7 +38,18 @@ export const useMinappPopup = () => {
const createLRUCache = useCallback(() => {
return new LRUCache<string, MinAppType>({
max: maxKeepAliveMinapps,
disposeAfter: () => {
disposeAfter: (_value, key) => {
// Clean up WebView state when app is disposed from cache
clearWebviewState(key)
// Close corresponding tab if it exists
const tabs = TabsService.getTabs()
const tabToClose = tabs.find((tab) => tab.path === `/apps/${key}`)
if (tabToClose) {
TabsService.closeTab(tabToClose.id)
}
// Update Redux state
dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values())))
},
onInsert: () => {
@ -158,6 +171,8 @@ export const useMinappPopup = () => {
openMinappById,
closeMinapp,
hideMinappPopup,
closeAllMinapps
closeAllMinapps,
// Expose cache instance for TabsService integration
minAppsCache
}
}

View File

@ -2,7 +2,6 @@ import App from '@renderer/components/MinApp/MinApp'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import tabsService from '@renderer/services/TabsService'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -105,7 +104,7 @@ const LaunchpadPage: FC = () => {
<Grid>
{sortedMinapps.map((app) => (
<AppWrapper key={app.id}>
<App app={app} size={56} onClick={() => setTimeout(() => tabsService.closeTab('launchpad'), 350)} />
<App app={app} size={56} />
</AppWrapper>
))}
</Grid>

View File

@ -0,0 +1,102 @@
import { loggerService } from '@logger'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
import TabsService from '@renderer/services/TabsService'
import { FC, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components'
import MinAppFullPageView from './components/MinAppFullPageView'
const logger = loggerService.withContext('MinAppPage')
const MinAppPage: FC = () => {
const { appId } = useParams<{ appId: string }>()
const { isTopNavbar } = useNavbarPosition()
const { openMinappKeepAlive, minAppsCache } = useMinappPopup()
const { minapps } = useMinapps()
const { openedKeepAliveMinapps } = useRuntime()
const navigate = useNavigate()
// Remember the initial navbar position when component mounts
const initialIsTopNavbar = useRef<boolean>(isTopNavbar)
const hasRedirected = useRef<boolean>(false)
// Initialize TabsService with cache reference
useEffect(() => {
if (minAppsCache) {
TabsService.setMinAppsCache(minAppsCache)
}
}, [minAppsCache])
// Debug: track navbar position changes
useEffect(() => {
if (initialIsTopNavbar.current !== isTopNavbar) {
logger.debug(`NavBar position changed from ${initialIsTopNavbar.current} to ${isTopNavbar}`)
}
}, [isTopNavbar])
// Find the app from all available apps
const app = useMemo(() => {
if (!appId) return null
return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
}, [appId, minapps])
useEffect(() => {
// If app not found, redirect to apps list
if (!app) {
navigate('/apps')
return
}
// For sidebar navigation, redirect to apps list and open popup
// Only check once and only if we haven't already redirected
if (!initialIsTopNavbar.current && !hasRedirected.current) {
hasRedirected.current = true
navigate('/apps')
// Open popup after navigation
setTimeout(() => {
openMinappKeepAlive(app)
}, 100)
return
}
// For top navbar mode, integrate with cache system
if (initialIsTopNavbar.current) {
// Check if app is already in the keep-alive cache
const isAlreadyInCache = openedKeepAliveMinapps.some((cachedApp) => cachedApp.id === app.id)
if (!isAlreadyInCache) {
logger.debug(`Adding app ${app.id} to keep-alive cache via openMinappKeepAlive`)
// Add to cache without showing popup (for tab mode)
openMinappKeepAlive(app)
} else {
logger.debug(`App ${app.id} already in keep-alive cache`)
}
}
}, [app, navigate, openMinappKeepAlive, openedKeepAliveMinapps, initialIsTopNavbar])
// Don't render anything if app not found or not in top navbar mode initially
if (!app || !initialIsTopNavbar.current) {
return null
}
return (
<Container>
<MinAppFullPageView app={app} />
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: hidden;
`
export default MinAppPage

View File

@ -0,0 +1,166 @@
import { loggerService } from '@logger'
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
import { useSettings } from '@renderer/hooks/useSettings'
import { MinAppType } from '@renderer/types'
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Avatar } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import MinimalToolbar from './MinimalToolbar'
const logger = loggerService.withContext('MinAppFullPageView')
interface Props {
app: MinAppType
}
const MinAppFullPageView: FC<Props> = ({ app }) => {
const webviewRef = useRef<WebviewTag | null>(null)
const [isReady, setIsReady] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
const { minappsOpenLinkExternal } = useSettings()
// Debug: log isReady state changes
useEffect(() => {
logger.debug(`isReady state changed to: ${isReady}`)
}, [isReady])
// Initialize when app changes - smart loading state detection using global state
useEffect(() => {
setCurrentUrl(app.url)
// Check if this WebView has been loaded before using global state manager
if (getWebviewLoaded(app.id)) {
logger.debug(`App ${app.id} already loaded before, setting ready immediately`)
setIsReady(true)
return // No cleanup needed for immediate ready state
} else {
logger.debug(`App ${app.id} not loaded before, showing loading state`)
setIsReady(false)
// Backup timer logic removed as requested—loading animation will show indefinitely if needed.
// (See version control history for previous implementation.)
}
}, [app])
const handleWebviewSetRef = useCallback((_appId: string, element: WebviewTag | null) => {
webviewRef.current = element
if (element) {
logger.debug('WebView element set')
}
}, [])
const handleWebviewLoaded = useCallback(
(appId: string) => {
logger.debug(`WebView loaded for app: ${appId}`)
const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
// Mark this WebView as loaded for future use in global state
setWebviewLoaded(appId, true)
// Use small delay like MinappPopupContainer (100ms) to ensure content is visible
if (appId === app.id) {
setTimeout(() => {
logger.debug(`WebView loaded callback: setting isReady to true for ${appId}`)
setIsReady(true)
}, 100)
}
},
[minappsOpenLinkExternal, app.id]
)
const handleWebviewNavigate = useCallback((_appId: string, url: string) => {
logger.debug(`URL changed: ${url}`)
setCurrentUrl(url)
}, [])
const handleReload = useCallback(() => {
if (webviewRef.current) {
// Clear the loaded state for this app since we're reloading using global state
setWebviewLoaded(app.id, false)
setIsReady(false) // Set loading state when reloading
webviewRef.current.src = app.url
}
}, [app.url, app.id])
const handleOpenDevTools = useCallback(() => {
if (webviewRef.current) {
webviewRef.current.openDevTools()
}
}, [])
return (
<Container>
<MinimalToolbar
app={app}
webviewRef={webviewRef}
currentUrl={currentUrl}
onReload={handleReload}
onOpenDevTools={handleOpenDevTools}
/>
<WebviewArea>
{!isReady && (
<LoadingMask>
<LoadingOverlay>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
<BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} />
</LoadingOverlay>
</LoadingMask>
)}
<WebviewContainer
key={app.id}
appid={app.id}
url={app.url}
onSetRefCallback={handleWebviewSetRef}
onLoadedCallback={handleWebviewLoaded}
onNavigateCallback={handleWebviewNavigate}
/>
</WebviewArea>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
`
const WebviewArea = styled.div`
flex: 1;
position: relative;
overflow: hidden;
background-color: var(--color-background);
min-height: 0; /* Ensure flex child can shrink */
`
const LoadingMask = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-background);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
`
const LoadingOverlay = styled.div`
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
`
export default MinAppFullPageView

View File

@ -0,0 +1,218 @@
import {
ArrowLeftOutlined,
ArrowRightOutlined,
CodeOutlined,
ExportOutlined,
LinkOutlined,
MinusOutlined,
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
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 { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface Props {
app: MinAppType
webviewRef: React.RefObject<WebviewTag | null>
currentUrl: string | null
onReload: () => void
onOpenDevTools: () => void
}
const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOpenDevTools }) => {
const { t } = useTranslation()
const { pinned, updatePinnedMinapps } = useMinapps()
const { minappsOpenLinkExternal } = useSettings()
const dispatch = useAppDispatch()
const navigate = useNavigate()
const [canGoBack, setCanGoBack] = useState(false)
const [canGoForward, setCanGoForward] = useState(false)
const isInDevelopment = process.env.NODE_ENV === 'development'
const canPinned = DEFAULT_MIN_APPS.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://')
// Update navigation state
const updateNavigationState = useCallback(() => {
if (webviewRef.current) {
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
}
}, [webviewRef])
const handleGoBack = useCallback(() => {
if (webviewRef.current && webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
updateNavigationState()
}
}, [webviewRef, updateNavigationState])
const handleGoForward = useCallback(() => {
if (webviewRef.current && webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
updateNavigationState()
}
}, [webviewRef, updateNavigationState])
const handleMinimize = useCallback(() => {
navigate('/apps')
}, [navigate])
const handleTogglePin = useCallback(() => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
updatePinnedMinapps(newPinned)
}, [app, isPinned, pinned, updatePinnedMinapps])
const handleToggleOpenExternal = useCallback(() => {
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
}, [dispatch, minappsOpenLinkExternal])
const handleOpenLink = useCallback(() => {
const urlToOpen = currentUrl || app.url
window.api.openWebsite(urlToOpen)
}, [currentUrl, app.url])
return (
<ToolbarContainer>
<LeftSection>
<ButtonGroup>
<Tooltip title={t('minapp.popup.goBack')} placement="bottom">
<ToolbarButton onClick={handleGoBack} $disabled={!canGoBack}>
<ArrowLeftOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('minapp.popup.goForward')} placement="bottom">
<ToolbarButton onClick={handleGoForward} $disabled={!canGoForward}>
<ArrowRightOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('minapp.popup.refresh')} placement="bottom">
<ToolbarButton onClick={onReload}>
<ReloadOutlined />
</ToolbarButton>
</Tooltip>
</ButtonGroup>
</LeftSection>
<RightSection>
<ButtonGroup>
{canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} placement="bottom">
<ToolbarButton onClick={handleOpenLink}>
<ExportOutlined />
</ToolbarButton>
</Tooltip>
)}
{canPinned && (
<Tooltip
title={isPinned ? t('minapp.remove_from_launchpad') : t('minapp.add_to_launchpad')}
placement="bottom">
<ToolbarButton onClick={handleTogglePin} $active={isPinned}>
<PushpinOutlined />
</ToolbarButton>
</Tooltip>
)}
<Tooltip
title={
minappsOpenLinkExternal
? t('minapp.popup.open_link_external_on')
: t('minapp.popup.open_link_external_off')
}
placement="bottom">
<ToolbarButton onClick={handleToggleOpenExternal} $active={minappsOpenLinkExternal}>
<LinkOutlined />
</ToolbarButton>
</Tooltip>
{isInDevelopment && (
<Tooltip title={t('minapp.popup.devtools')} placement="bottom">
<ToolbarButton onClick={onOpenDevTools}>
<CodeOutlined />
</ToolbarButton>
</Tooltip>
)}
<Tooltip title={t('minapp.popup.minimize')} placement="bottom">
<ToolbarButton onClick={handleMinimize}>
<MinusOutlined />
</ToolbarButton>
</Tooltip>
</ButtonGroup>
</RightSection>
</ToolbarContainer>
)
}
const ToolbarContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
height: 35px;
padding: 0 12px;
background-color: var(--color-background);
flex-shrink: 0;
`
const LeftSection = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const RightSection = styled.div`
display: flex;
align-items: center;
`
const ButtonGroup = styled.div`
display: flex;
align-items: center;
gap: 2px;
`
const ToolbarButton = styled.button<{
$disabled?: boolean
$active?: boolean
}>`
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: ${({ $active }) => ($active ? 'var(--color-primary-bg)' : 'transparent')};
color: ${({ $disabled, $active }) =>
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-2)'};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
transition: all 0.2s ease;
font-size: 12px;
&:hover {
background: ${({ $disabled, $active }) =>
$disabled ? 'transparent' : $active ? 'var(--color-primary-bg)' : 'var(--color-background-soft)'};
color: ${({ $disabled, $active }) =>
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-1)'};
}
&:active {
transform: ${({ $disabled }) => ($disabled ? 'none' : 'scale(0.95)')};
}
`
export default MinimalToolbar

View File

@ -1,12 +1,29 @@
import { loggerService } from '@logger'
import store from '@renderer/store'
import { removeTab, setActiveTab } from '@renderer/store/tabs'
import { MinAppType } from '@renderer/types'
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
import { LRUCache } from 'lru-cache'
import NavigationService from './NavigationService'
const logger = loggerService.withContext('TabsService')
class TabsService {
private minAppsCache: LRUCache<string, MinAppType> | null = null
/**
* Sets the reference to the mini-apps LRU cache used for managing mini-app lifecycle and cleanup.
* This method is required to integrate TabsService with the mini-apps cache system, allowing TabsService
* to perform cache cleanup when tabs associated with mini-apps are closed. The cache instance is typically
* provided by the mini-app popup system and enables TabsService to maintain cache consistency and prevent
* stale data.
* @param cache The LRUCache instance containing mini-app data, provided by useMinappPopup.
*/
public setMinAppsCache(cache: LRUCache<string, MinAppType>) {
this.minAppsCache = cache
logger.debug('Mini-apps cache reference set in TabsService')
}
/**
*
* @param tabId ID
@ -49,6 +66,9 @@ class TabsService {
}
}
// Clean up mini-app cache if this is a mini-app tab
this.cleanupMinAppCache(tabId)
// 使用 Redux action 移除标签页
store.dispatch(removeTab(tabId))
@ -56,6 +76,32 @@ class TabsService {
return true
}
/**
* Clean up mini-app cache and WebView state when tab is closed
* @param tabId The tab ID to clean up
*/
private cleanupMinAppCache(tabId: string) {
// Check if this is a mini-app tab (format: /apps/{appId})
const tabs = store.getState().tabs.tabs
const tab = tabs.find((t) => t.id === tabId)
if (tab && tab.path.startsWith('/apps/')) {
const appId = tab.path.replace('/apps/', '')
if (this.minAppsCache && this.minAppsCache.has(appId)) {
logger.debug(`Cleaning up mini-app cache for app: ${appId}`)
// Remove from LRU cache - this will trigger disposeAfter callback
this.minAppsCache.delete(appId)
// Clear WebView state
clearWebviewState(appId)
logger.info(`Mini-app ${appId} removed from cache due to tab closure`)
}
}
}
/**
*
*/

View File

@ -0,0 +1,55 @@
import { loggerService } from '@logger'
const logger = loggerService.withContext('WebviewStateManager')
// Global WebView loaded states - shared between popup and tab modes
const globalWebviewStates = new Map<string, boolean>()
/**
* Set WebView loaded state for a specific app
* @param appId - The mini-app ID
* @param loaded - Whether the WebView is loaded
*/
export const setWebviewLoaded = (appId: string, loaded: boolean) => {
globalWebviewStates.set(appId, loaded)
logger.debug(`WebView state set for ${appId}: ${loaded}`)
}
/**
* Get WebView loaded state for a specific app
* @param appId - The mini-app ID
* @returns Whether the WebView is loaded
*/
export const getWebviewLoaded = (appId: string): boolean => {
return globalWebviewStates.get(appId) || false
}
/**
* Clear WebView state for a specific app
* @param appId - The mini-app ID
*/
export const clearWebviewState = (appId: string) => {
const wasLoaded = globalWebviewStates.delete(appId)
if (wasLoaded) {
logger.debug(`WebView state cleared for ${appId}`)
}
}
/**
* Clear all WebView states
*/
export const clearAllWebviewStates = () => {
const count = globalWebviewStates.size
globalWebviewStates.clear()
logger.debug(`Cleared all WebView states (${count} apps)`)
}
/**
* Get all loaded app IDs
* @returns Array of app IDs that have loaded WebViews
*/
export const getLoadedAppIds = (): string[] => {
return Array.from(globalWebviewStates.entries())
.filter(([, loaded]) => loaded)
.map(([appId]) => appId)
}