Compare commits
15 Commits
feat/messa
...
v1.5.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9f59146e | ||
|
|
c1d8bf38ef | ||
|
|
7217a7216e | ||
|
|
d35998bd74 | ||
|
|
dbd090377d | ||
|
|
2ebcb43d50 | ||
|
|
826b71deba | ||
|
|
7c0a800d9d | ||
|
|
6f9906fe49 | ||
|
|
ba7eec64b0 | ||
|
|
83d2403339 | ||
|
|
bc17dcb911 | ||
|
|
44e93671fa | ||
|
|
a5bfd8f3db | ||
|
|
07c3c33acc |
@@ -110,6 +110,10 @@ linux:
|
|||||||
StartupWMClass: CherryStudio
|
StartupWMClass: CherryStudio
|
||||||
mimeTypes:
|
mimeTypes:
|
||||||
- x-scheme-handler/cherrystudio
|
- x-scheme-handler/cherrystudio
|
||||||
|
rpm:
|
||||||
|
# Workaround for electron build issue on rpm package:
|
||||||
|
# https://github.com/electron/forge/issues/3594
|
||||||
|
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
|
||||||
publish:
|
publish:
|
||||||
provider: generic
|
provider: generic
|
||||||
url: https://releases.cherry-ai.com
|
url: https://releases.cherry-ai.com
|
||||||
@@ -121,24 +125,6 @@ afterSign: scripts/notarize.js
|
|||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
✨ 重要更新:
|
Top navigation bar mode will display the mini-program using tabs
|
||||||
- 新增笔记模块,支持富文本编辑和管理
|
Fixed the issue with Google mini-program login
|
||||||
- 内置 GLM-4.5-Flash 免费模型(由智谱开放平台提供)
|
Notes support drag and drop sorting
|
||||||
- 内置 Qwen3-8B 免费模型(由硅基流动提供)
|
|
||||||
- 新增 Nano Banana(Gemini 2.5 Flash Image)模型支持
|
|
||||||
- 新增系统 OCR 功能 (macOS & Windows)
|
|
||||||
- 新增图片 OCR 识别和翻译功能
|
|
||||||
- 模型切换支持通过标签筛选
|
|
||||||
- 翻译功能增强:历史搜索和收藏
|
|
||||||
|
|
||||||
🔧 性能优化:
|
|
||||||
- 优化历史页面搜索性能
|
|
||||||
- 优化拖拽列表组件交互
|
|
||||||
- 升级 Electron 到 37.4.0
|
|
||||||
|
|
||||||
🐛 修复问题:
|
|
||||||
- 修复知识库加密 PDF 文档处理
|
|
||||||
- 修复导航栏在左侧时笔记侧边栏按钮缺失
|
|
||||||
- 修复多个模型兼容性问题
|
|
||||||
- 修复 MCP 相关问题
|
|
||||||
- 其他稳定性改进
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.5.9",
|
"version": "1.5.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export default class AppUpdater {
|
|||||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||||
autoUpdater.requestHeaders = {
|
autoUpdater.requestHeaders = {
|
||||||
...autoUpdater.requestHeaders,
|
...autoUpdater.requestHeaders,
|
||||||
'User-Agent': generateUserAgent()
|
'User-Agent': generateUserAgent(),
|
||||||
|
'X-Client-Id': configManager.getClientId()
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.on('error', (error) => {
|
autoUpdater.on('error', (error) => {
|
||||||
|
|||||||
@@ -332,14 +332,15 @@ class CodeToolsService {
|
|||||||
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
|
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
|
||||||
const envPrefix = buildEnvPrefix(false)
|
const envPrefix = buildEnvPrefix(false)
|
||||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||||
|
// Combine directory change with the main command to ensure they execute in the same shell session
|
||||||
|
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
|
||||||
|
|
||||||
terminalCommand = 'osascript'
|
terminalCommand = 'osascript'
|
||||||
terminalArgs = [
|
terminalArgs = [
|
||||||
'-e',
|
'-e',
|
||||||
`tell application "Terminal"
|
`tell application "Terminal"
|
||||||
set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear"
|
do script "${fullCommand.replace(/"/g, '\\"')}"
|
||||||
activate
|
activate
|
||||||
do script "${command.replace(/"/g, '\\"')}" in newTab
|
|
||||||
end tell`
|
end tell`
|
||||||
]
|
]
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/
|
|||||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
import { locales } from '../utils/locales'
|
import { locales } from '../utils/locales'
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ export enum ConfigKeys {
|
|||||||
SelectionAssistantFilterList = 'selectionAssistantFilterList',
|
SelectionAssistantFilterList = 'selectionAssistantFilterList',
|
||||||
DisableHardwareAcceleration = 'disableHardwareAcceleration',
|
DisableHardwareAcceleration = 'disableHardwareAcceleration',
|
||||||
Proxy = 'proxy',
|
Proxy = 'proxy',
|
||||||
EnableDeveloperMode = 'enableDeveloperMode'
|
EnableDeveloperMode = 'enableDeveloperMode',
|
||||||
|
ClientId = 'clientId'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
@@ -241,6 +243,17 @@ export class ConfigManager {
|
|||||||
this.set(ConfigKeys.EnableDeveloperMode, value)
|
this.set(ConfigKeys.EnableDeveloperMode, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getClientId(): string {
|
||||||
|
let clientId = this.get<string>(ConfigKeys.ClientId)
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
clientId = uuidv4()
|
||||||
|
this.set(ConfigKeys.ClientId, clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientId
|
||||||
|
}
|
||||||
|
|
||||||
set(key: string, value: unknown, isNotify: boolean = false) {
|
set(key: string, value: unknown, isNotify: boolean = false) {
|
||||||
this.store.set(key, value)
|
this.store.set(key, value)
|
||||||
isNotify && this.notifySubscribers(key, value)
|
isNotify && this.notifySubscribers(key, value)
|
||||||
|
|||||||
@@ -56,6 +56,45 @@ type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: strin
|
|||||||
|
|
||||||
const logger = loggerService.withContext('MCPService')
|
const logger = loggerService.withContext('MCPService')
|
||||||
|
|
||||||
|
// Redact potentially sensitive fields in objects (headers, tokens, api keys)
|
||||||
|
function redactSensitive(input: any): any {
|
||||||
|
const SENSITIVE_KEYS = ['authorization', 'Authorization', 'apiKey', 'api_key', 'apikey', 'token', 'access_token']
|
||||||
|
const MAX_STRING = 300
|
||||||
|
|
||||||
|
const redact = (val: any): any => {
|
||||||
|
if (val == null) return val
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
return val.length > MAX_STRING ? `${val.slice(0, MAX_STRING)}…<${val.length - MAX_STRING} more>` : val
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) return val.map((v) => redact(v))
|
||||||
|
if (typeof val === 'object') {
|
||||||
|
const out: Record<string, any> = {}
|
||||||
|
for (const [k, v] of Object.entries(val)) {
|
||||||
|
if (SENSITIVE_KEYS.includes(k)) {
|
||||||
|
out[k] = '<redacted>'
|
||||||
|
} else {
|
||||||
|
out[k] = redact(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
return redact(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a context-aware logger for a server
|
||||||
|
function getServerLogger(server: MCPServer, extra?: Record<string, any>) {
|
||||||
|
const base = {
|
||||||
|
serverName: server?.name,
|
||||||
|
serverId: server?.id,
|
||||||
|
baseUrl: server?.baseUrl,
|
||||||
|
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
|
||||||
|
}
|
||||||
|
return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Higher-order function to add caching capability to any async function
|
* Higher-order function to add caching capability to any async function
|
||||||
* @param fn The original function to be wrapped with caching
|
* @param fn The original function to be wrapped with caching
|
||||||
@@ -74,15 +113,17 @@ function withCache<T extends unknown[], R>(
|
|||||||
const cacheKey = getCacheKey(...args)
|
const cacheKey = getCacheKey(...args)
|
||||||
|
|
||||||
if (CacheService.has(cacheKey)) {
|
if (CacheService.has(cacheKey)) {
|
||||||
logger.debug(`${logPrefix} loaded from cache`)
|
logger.debug(`${logPrefix} loaded from cache`, { cacheKey })
|
||||||
const cachedData = CacheService.get<R>(cacheKey)
|
const cachedData = CacheService.get<R>(cacheKey)
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return cachedData
|
return cachedData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const start = Date.now()
|
||||||
const result = await fn(...args)
|
const result = await fn(...args)
|
||||||
CacheService.set(cacheKey, result, ttl)
|
CacheService.set(cacheKey, result, ttl)
|
||||||
|
logger.debug(`${logPrefix} cached`, { cacheKey, ttlMs: ttl, durationMs: Date.now() - start })
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +169,7 @@ class McpService {
|
|||||||
// If there's a pending initialization, wait for it
|
// If there's a pending initialization, wait for it
|
||||||
const pendingClient = this.pendingClients.get(serverKey)
|
const pendingClient = this.pendingClients.get(serverKey)
|
||||||
if (pendingClient) {
|
if (pendingClient) {
|
||||||
|
getServerLogger(server).silly(`Waiting for pending client initialization`)
|
||||||
return pendingClient
|
return pendingClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +178,11 @@ class McpService {
|
|||||||
if (existingClient) {
|
if (existingClient) {
|
||||||
try {
|
try {
|
||||||
// Check if the existing client is still connected
|
// Check if the existing client is still connected
|
||||||
const pingResult = await existingClient.ping()
|
const pingResult = await existingClient.ping({
|
||||||
logger.debug(`Ping result for ${server.name}:`, pingResult)
|
// add short timeout to prevent hanging
|
||||||
|
timeout: 1000
|
||||||
|
})
|
||||||
|
getServerLogger(server).debug(`Ping result`, { ok: !!pingResult })
|
||||||
// If the ping fails, remove the client from the cache
|
// If the ping fails, remove the client from the cache
|
||||||
// and create a new one
|
// and create a new one
|
||||||
if (!pingResult) {
|
if (!pingResult) {
|
||||||
@@ -146,7 +191,7 @@ class McpService {
|
|||||||
return existingClient
|
return existingClient
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error pinging server ${server.name}:`, error?.message)
|
getServerLogger(server).error(`Error pinging server`, error as Error)
|
||||||
this.clients.delete(serverKey)
|
this.clients.delete(serverKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,15 +217,15 @@ class McpService {
|
|||||||
> => {
|
> => {
|
||||||
// Create appropriate transport based on configuration
|
// Create appropriate transport based on configuration
|
||||||
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
|
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
|
||||||
logger.debug(`Using in-memory transport for server: ${server.name}`)
|
getServerLogger(server).debug(`Using in-memory transport`)
|
||||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||||
// start the in-memory server with the given name and environment variables
|
// start the in-memory server with the given name and environment variables
|
||||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||||
try {
|
try {
|
||||||
await inMemoryServer.connect(serverTransport)
|
await inMemoryServer.connect(serverTransport)
|
||||||
logger.debug(`In-memory server started: ${server.name}`)
|
getServerLogger(server).debug(`In-memory server started`)
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
logger.error(`Error starting in-memory server: ${error}`)
|
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
|
||||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||||
}
|
}
|
||||||
// set the client transport to the client
|
// set the client transport to the client
|
||||||
@@ -193,7 +238,10 @@ class McpService {
|
|||||||
},
|
},
|
||||||
authProvider
|
authProvider
|
||||||
}
|
}
|
||||||
logger.debug(`StreamableHTTPClientTransport options:`, options)
|
// redact headers before logging
|
||||||
|
getServerLogger(server).debug(`StreamableHTTPClientTransport options`, {
|
||||||
|
options: redactSensitive(options)
|
||||||
|
})
|
||||||
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||||
} else if (server.type === 'sse') {
|
} else if (server.type === 'sse') {
|
||||||
const options: SSEClientTransportOptions = {
|
const options: SSEClientTransportOptions = {
|
||||||
@@ -209,7 +257,7 @@ class McpService {
|
|||||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch tokens:', error as Error)
|
getServerLogger(server).error('Failed to fetch tokens:', error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,15 +287,18 @@ class McpService {
|
|||||||
...server.env,
|
...server.env,
|
||||||
...resolvedConfig.env
|
...resolvedConfig.env
|
||||||
}
|
}
|
||||||
logger.debug(`Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`)
|
getServerLogger(server).debug(`Using resolved DXT config`, {
|
||||||
|
command: cmd,
|
||||||
|
args
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Failed to resolve DXT config for ${server.name}, falling back to manifest values`)
|
getServerLogger(server).warn(`Failed to resolve DXT config, falling back to manifest values`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.command === 'npx') {
|
if (server.command === 'npx') {
|
||||||
cmd = await getBinaryPath('bun')
|
cmd = await getBinaryPath('bun')
|
||||||
logger.debug(`Using command: ${cmd}`)
|
getServerLogger(server).debug(`Using command`, { command: cmd })
|
||||||
|
|
||||||
// add -x to args if args exist
|
// add -x to args if args exist
|
||||||
if (args && args.length > 0) {
|
if (args && args.length > 0) {
|
||||||
@@ -282,7 +333,7 @@ class McpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
|
||||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||||
const loginShellEnv = await this.getLoginShellEnv()
|
const loginShellEnv = await this.getLoginShellEnv()
|
||||||
|
|
||||||
@@ -304,12 +355,14 @@ class McpService {
|
|||||||
// For DXT servers, set the working directory to the extracted path
|
// For DXT servers, set the working directory to the extracted path
|
||||||
if (server.dxtPath) {
|
if (server.dxtPath) {
|
||||||
transportOptions.cwd = server.dxtPath
|
transportOptions.cwd = server.dxtPath
|
||||||
logger.debug(`Setting working directory for DXT server: ${server.dxtPath}`)
|
getServerLogger(server).debug(`Setting working directory for DXT server`, {
|
||||||
|
cwd: server.dxtPath
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const stdioTransport = new StdioClientTransport(transportOptions)
|
const stdioTransport = new StdioClientTransport(transportOptions)
|
||||||
stdioTransport.stderr?.on('data', (data) =>
|
stdioTransport.stderr?.on('data', (data) =>
|
||||||
logger.debug(`Stdio stderr for server: ${server.name}` + data.toString())
|
getServerLogger(server).debug(`Stdio stderr`, { data: data.toString() })
|
||||||
)
|
)
|
||||||
return stdioTransport
|
return stdioTransport
|
||||||
} else {
|
} else {
|
||||||
@@ -318,7 +371,7 @@ class McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
||||||
logger.debug(`Starting OAuth flow for server: ${server.name}`)
|
getServerLogger(server).debug(`Starting OAuth flow`)
|
||||||
// Create an event emitter for the OAuth callback
|
// Create an event emitter for the OAuth callback
|
||||||
const events = new EventEmitter()
|
const events = new EventEmitter()
|
||||||
|
|
||||||
@@ -331,27 +384,27 @@ class McpService {
|
|||||||
|
|
||||||
// Set a timeout to close the callback server
|
// Set a timeout to close the callback server
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
logger.warn(`OAuth flow timed out for server: ${server.name}`)
|
getServerLogger(server).warn(`OAuth flow timed out`)
|
||||||
callbackServer.close()
|
callbackServer.close()
|
||||||
}, 300000) // 5 minutes timeout
|
}, 300000) // 5 minutes timeout
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Wait for the authorization code
|
// Wait for the authorization code
|
||||||
const authCode = await callbackServer.waitForAuthCode()
|
const authCode = await callbackServer.waitForAuthCode()
|
||||||
logger.debug(`Received auth code: ${authCode}`)
|
getServerLogger(server).debug(`Received auth code`)
|
||||||
|
|
||||||
// Complete the OAuth flow
|
// Complete the OAuth flow
|
||||||
await transport.finishAuth(authCode)
|
await transport.finishAuth(authCode)
|
||||||
|
|
||||||
logger.debug(`OAuth flow completed for server: ${server.name}`)
|
getServerLogger(server).debug(`OAuth flow completed`)
|
||||||
|
|
||||||
const newTransport = await initTransport()
|
const newTransport = await initTransport()
|
||||||
// Try to connect again
|
// Try to connect again
|
||||||
await client.connect(newTransport)
|
await client.connect(newTransport)
|
||||||
|
|
||||||
logger.debug(`Successfully authenticated with server: ${server.name}`)
|
getServerLogger(server).debug(`Successfully authenticated`)
|
||||||
} catch (oauthError) {
|
} catch (oauthError) {
|
||||||
logger.error(`OAuth authentication failed for server ${server.name}:`, oauthError as Error)
|
getServerLogger(server).error(`OAuth authentication failed`, oauthError as Error)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
||||||
)
|
)
|
||||||
@@ -390,7 +443,7 @@ class McpService {
|
|||||||
logger.debug(`Activated server: ${server.name}`)
|
logger.debug(`Activated server: ${server.name}`)
|
||||||
return client
|
return client
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error activating server ${server.name}:`, error?.message)
|
getServerLogger(server).error(`Error activating server`, error as Error)
|
||||||
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -450,9 +503,9 @@ class McpService {
|
|||||||
logger.debug(`Message from server ${server.name}:`, notification.params)
|
logger.debug(`Message from server ${server.name}:`, notification.params)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.debug(`Set up notification handlers for server: ${server.name}`)
|
getServerLogger(server).debug(`Set up notification handlers`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to set up notification handlers for server ${server.name}:`, error as Error)
|
getServerLogger(server).error(`Failed to set up notification handlers`, error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +523,7 @@ class McpService {
|
|||||||
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
||||||
CacheService.remove(`mcp:list_prompts:${serverKey}`)
|
CacheService.remove(`mcp:list_prompts:${serverKey}`)
|
||||||
CacheService.remove(`mcp:list_resources:${serverKey}`)
|
CacheService.remove(`mcp:list_resources:${serverKey}`)
|
||||||
logger.debug(`Cleared all caches for server: ${serverKey}`)
|
logger.debug(`Cleared all caches for server`, { serverKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeClient(serverKey: string) {
|
async closeClient(serverKey: string) {
|
||||||
@@ -478,18 +531,18 @@ class McpService {
|
|||||||
if (client) {
|
if (client) {
|
||||||
// Remove the client from the cache
|
// Remove the client from the cache
|
||||||
await client.close()
|
await client.close()
|
||||||
logger.debug(`Closed server: ${serverKey}`)
|
logger.debug(`Closed server`, { serverKey })
|
||||||
this.clients.delete(serverKey)
|
this.clients.delete(serverKey)
|
||||||
// Clear all caches for this server
|
// Clear all caches for this server
|
||||||
this.clearServerCache(serverKey)
|
this.clearServerCache(serverKey)
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`No client found for server: ${serverKey}`)
|
logger.warn(`No client found for server`, { serverKey })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
const serverKey = this.getServerKey(server)
|
const serverKey = this.getServerKey(server)
|
||||||
logger.debug(`Stopping server: ${server.name}`)
|
getServerLogger(server).debug(`Stopping server`)
|
||||||
await this.closeClient(serverKey)
|
await this.closeClient(serverKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,16 +558,16 @@ class McpService {
|
|||||||
try {
|
try {
|
||||||
const cleaned = this.dxtService.cleanupDxtServer(server.name)
|
const cleaned = this.dxtService.cleanupDxtServer(server.name)
|
||||||
if (cleaned) {
|
if (cleaned) {
|
||||||
logger.debug(`Cleaned up DXT server directory for: ${server.name}`)
|
getServerLogger(server).debug(`Cleaned up DXT server directory`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to cleanup DXT server: ${server.name}`, error as Error)
|
getServerLogger(server).error(`Failed to cleanup DXT server`, error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
logger.debug(`Restarting server: ${server.name}`)
|
getServerLogger(server).debug(`Restarting server`)
|
||||||
const serverKey = this.getServerKey(server)
|
const serverKey = this.getServerKey(server)
|
||||||
await this.closeClient(serverKey)
|
await this.closeClient(serverKey)
|
||||||
// Clear cache before restarting to ensure fresh data
|
// Clear cache before restarting to ensure fresh data
|
||||||
@@ -527,7 +580,7 @@ class McpService {
|
|||||||
try {
|
try {
|
||||||
await this.closeClient(key)
|
await this.closeClient(key)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Failed to close client: ${error?.message}`)
|
logger.error(`Failed to close client`, error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,9 +589,9 @@ class McpService {
|
|||||||
* Check connectivity for an MCP server
|
* Check connectivity for an MCP server
|
||||||
*/
|
*/
|
||||||
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
|
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
|
||||||
logger.debug(`Checking connectivity for server: ${server.name}`)
|
getServerLogger(server).debug(`Checking connectivity`)
|
||||||
try {
|
try {
|
||||||
logger.debug(`About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient })
|
getServerLogger(server).debug(`About to call initClient`, { hasInitClient: !!this.initClient })
|
||||||
|
|
||||||
if (!this.initClient) {
|
if (!this.initClient) {
|
||||||
throw new Error('initClient method is not available')
|
throw new Error('initClient method is not available')
|
||||||
@@ -547,10 +600,10 @@ class McpService {
|
|||||||
const client = await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
// Attempt to list tools as a way to check connectivity
|
// Attempt to list tools as a way to check connectivity
|
||||||
await client.listTools()
|
await client.listTools()
|
||||||
logger.debug(`Connectivity check successful for server: ${server.name}`)
|
getServerLogger(server).debug(`Connectivity check successful`)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Connectivity check failed for server: ${server.name}`, error as Error)
|
getServerLogger(server).error(`Connectivity check failed`, error as Error)
|
||||||
// Close the client if connectivity check fails to ensure a clean state for the next attempt
|
// Close the client if connectivity check fails to ensure a clean state for the next attempt
|
||||||
const serverKey = this.getServerKey(server)
|
const serverKey = this.getServerKey(server)
|
||||||
await this.closeClient(serverKey)
|
await this.closeClient(serverKey)
|
||||||
@@ -559,9 +612,8 @@ class McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
||||||
logger.debug(`Listing tools for server: ${server.name}`)
|
getServerLogger(server).debug(`Listing tools`)
|
||||||
const client = await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
logger.debug(`Client for server: ${server.name}`, client)
|
|
||||||
try {
|
try {
|
||||||
const { tools } = await client.listTools()
|
const { tools } = await client.listTools()
|
||||||
const serverTools: MCPTool[] = []
|
const serverTools: MCPTool[] = []
|
||||||
@@ -576,7 +628,7 @@ class McpService {
|
|||||||
})
|
})
|
||||||
return serverTools
|
return serverTools
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Failed to list tools for server: ${server.name}`, error?.message)
|
getServerLogger(server).error(`Failed to list tools`, error as Error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,12 +665,16 @@ class McpService {
|
|||||||
|
|
||||||
const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
|
const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Calling: ${server.name} ${name} ${JSON.stringify(args)} callId: ${toolCallId}`, server)
|
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Calling tool`, {
|
||||||
|
args: redactSensitive(args)
|
||||||
|
})
|
||||||
if (typeof args === 'string') {
|
if (typeof args === 'string') {
|
||||||
try {
|
try {
|
||||||
args = JSON.parse(args)
|
args = JSON.parse(args)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('args parse error', args)
|
getServerLogger(server, { tool: name, callId: toolCallId }).error('args parse error', e as Error, {
|
||||||
|
args
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (args === '') {
|
if (args === '') {
|
||||||
args = {}
|
args = {}
|
||||||
@@ -627,8 +683,9 @@ class McpService {
|
|||||||
const client = await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||||
onprogress: (process) => {
|
onprogress: (process) => {
|
||||||
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
|
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Progress`, {
|
||||||
logger.debug(`Progress notification received for server: ${server.name}`, process)
|
ratio: process.progress / (process.total || 1)
|
||||||
|
})
|
||||||
const mainWindow = windowService.getMainWindow()
|
const mainWindow = windowService.getMainWindow()
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
|
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
|
||||||
@@ -643,7 +700,7 @@ class McpService {
|
|||||||
})
|
})
|
||||||
return result as MCPCallToolResponse
|
return result as MCPCallToolResponse
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error calling tool ${name} on ${server.name}:`, error as Error)
|
getServerLogger(server, { tool: name, callId: toolCallId }).error(`Error calling tool`, error as Error)
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
this.activeToolCalls.delete(toolCallId)
|
this.activeToolCalls.delete(toolCallId)
|
||||||
@@ -667,7 +724,7 @@ class McpService {
|
|||||||
*/
|
*/
|
||||||
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
|
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
|
||||||
const client = await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
logger.debug(`Listing prompts for server: ${server.name}`)
|
getServerLogger(server).debug(`Listing prompts`)
|
||||||
try {
|
try {
|
||||||
const { prompts } = await client.listPrompts()
|
const { prompts } = await client.listPrompts()
|
||||||
return prompts.map((prompt: any) => ({
|
return prompts.map((prompt: any) => ({
|
||||||
@@ -679,7 +736,7 @@ class McpService {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// -32601 is the code for the method not found
|
// -32601 is the code for the method not found
|
||||||
if (error?.code !== -32601) {
|
if (error?.code !== -32601) {
|
||||||
logger.error(`Failed to list prompts for server: ${server.name}`, error?.message)
|
getServerLogger(server).error(`Failed to list prompts`, error as Error)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -748,7 +805,7 @@ class McpService {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// -32601 is the code for the method not found
|
// -32601 is the code for the method not found
|
||||||
if (error?.code !== -32601) {
|
if (error?.code !== -32601) {
|
||||||
logger.error(`Failed to list resources for server: ${server.name}`, error?.message)
|
getServerLogger(server).error(`Failed to list resources`, error as Error)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -774,7 +831,7 @@ class McpService {
|
|||||||
* Get a specific resource from an MCP server (implementation)
|
* Get a specific resource from an MCP server (implementation)
|
||||||
*/
|
*/
|
||||||
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
|
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
|
||||||
logger.debug(`Getting resource ${uri} from server: ${server.name}`)
|
getServerLogger(server, { uri }).debug(`Getting resource`)
|
||||||
const client = await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
try {
|
try {
|
||||||
const result = await client.readResource({ uri: uri })
|
const result = await client.readResource({ uri: uri })
|
||||||
@@ -792,7 +849,7 @@ class McpService {
|
|||||||
contents: contents
|
contents: contents
|
||||||
}
|
}
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
logger.error(`Failed to get resource ${uri} from server: ${server.name}`, error.message)
|
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
|
||||||
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
|
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,10 +894,10 @@ class McpService {
|
|||||||
if (activeToolCall) {
|
if (activeToolCall) {
|
||||||
activeToolCall.abort()
|
activeToolCall.abort()
|
||||||
this.activeToolCalls.delete(callId)
|
this.activeToolCalls.delete(callId)
|
||||||
logger.debug(`Aborted tool call: ${callId}`)
|
logger.debug(`Aborted tool call`, { callId })
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`No active tool call found for callId: ${callId}`)
|
logger.warn(`No active tool call found for callId`, { callId })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,22 +907,22 @@ class McpService {
|
|||||||
*/
|
*/
|
||||||
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
|
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Getting server version for: ${server.name}`)
|
getServerLogger(server).debug(`Getting server version`)
|
||||||
const client = await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
|
|
||||||
// Try to get server information which may include version
|
// Try to get server information which may include version
|
||||||
const serverInfo = client.getServerVersion()
|
const serverInfo = client.getServerVersion()
|
||||||
logger.debug(`Server info for ${server.name}:`, serverInfo)
|
getServerLogger(server).debug(`Server info`, redactSensitive(serverInfo))
|
||||||
|
|
||||||
if (serverInfo && serverInfo.version) {
|
if (serverInfo && serverInfo.version) {
|
||||||
logger.debug(`Server version for ${server.name}: ${serverInfo.version}`)
|
getServerLogger(server).debug(`Server version`, { version: serverInfo.version })
|
||||||
return serverInfo.version
|
return serverInfo.version
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(`No version information available for server: ${server.name}`)
|
getServerLogger(server).warn(`No version information available`)
|
||||||
return null
|
return null
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Failed to get server version for ${server.name}:`, error?.message)
|
getServerLogger(server).error(`Failed to get server version`, error as Error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function initSessionUserAgent() {
|
|||||||
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
|
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||||
const headers = {
|
const headers = {
|
||||||
...details.requestHeaders,
|
...details.requestHeaders,
|
||||||
'User-Agent': newUA
|
'User-Agent': details.url.includes('google.com') ? originUA : newUA
|
||||||
}
|
}
|
||||||
cb({ requestHeaders: headers })
|
cb({ requestHeaders: headers })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import FilesPage from './pages/files/FilesPage'
|
|||||||
import HomePage from './pages/home/HomePage'
|
import HomePage from './pages/home/HomePage'
|
||||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||||
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
|
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
|
||||||
|
import MinAppPage from './pages/minapps/MinAppPage'
|
||||||
import MinAppsPage from './pages/minapps/MinAppsPage'
|
import MinAppsPage from './pages/minapps/MinAppsPage'
|
||||||
import NotesPage from './pages/notes/NotesPage'
|
import NotesPage from './pages/notes/NotesPage'
|
||||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||||
@@ -34,6 +35,7 @@ const Router: FC = () => {
|
|||||||
<Route path="/files" element={<FilesPage />} />
|
<Route path="/files" element={<FilesPage />} />
|
||||||
<Route path="/notes" element={<NotesPage />} />
|
<Route path="/notes" element={<NotesPage />} />
|
||||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||||
|
<Route path="/apps/:appId" element={<MinAppPage />} />
|
||||||
<Route path="/apps" element={<MinAppsPage />} />
|
<Route path="/apps" element={<MinAppsPage />} />
|
||||||
<Route path="/code" element={<CodeToolsPage />} />
|
<Route path="/code" element={<CodeToolsPage />} />
|
||||||
<Route path="/settings/*" element={<SettingsPage />} />
|
<Route path="/settings/*" element={<SettingsPage />} />
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB |
@@ -165,9 +165,6 @@ ul {
|
|||||||
}
|
}
|
||||||
.markdown {
|
.markdown {
|
||||||
display: flow-root;
|
display: flow-root;
|
||||||
*:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Dropdown } from 'antd'
|
|||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDispatch } from 'react-redux'
|
import { useDispatch } from 'react-redux'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +31,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
|||||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||||
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
|
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
const isPinned = pinned.some((p) => p.id === app.id)
|
const isPinned = pinned.some((p) => p.id === app.id)
|
||||||
const isVisible = minapps.some((m) => m.id === app.id)
|
const isVisible = minapps.some((m) => m.id === app.id)
|
||||||
const isActive = minappShow && currentMinappId === 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 { isTopNavbar } = useNavbarPosition()
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
openMinappKeepAlive(app)
|
if (isTopNavbar) {
|
||||||
|
// 顶部导航栏:导航到小程序页面
|
||||||
|
navigate(`/apps/${app.id}`)
|
||||||
|
} else {
|
||||||
|
// 侧边导航栏:保持原有弹窗行为
|
||||||
|
openMinappKeepAlive(app)
|
||||||
|
}
|
||||||
onClick?.()
|
onClick?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
143
src/renderer/src/components/MinApp/MinAppTabsPool.tsx
Normal file
143
src/renderer/src/components/MinApp/MinAppTabsPool.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
|
||||||
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||||
|
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||||
|
import { WebviewTag } from 'electron'
|
||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini-app WebView pool for Tab 模式 (顶部导航).
|
||||||
|
*
|
||||||
|
* 与 Popup 模式相似,但独立存在:
|
||||||
|
* - 仅在 isTopNavbar=true 且访问 /apps 路由时显示
|
||||||
|
* - 保证已打开的 keep-alive 小程序对应的 <webview> 不被卸载,只通过 display 切换
|
||||||
|
* - LRU 淘汰通过 openedKeepAliveMinapps 变化自动移除 DOM
|
||||||
|
*
|
||||||
|
* 后续可演进:与 Popup 共享同一实例(方案 B)。
|
||||||
|
*/
|
||||||
|
const logger = loggerService.withContext('MinAppTabsPool')
|
||||||
|
|
||||||
|
const MinAppTabsPool: React.FC = () => {
|
||||||
|
const { openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||||
|
const { isTopNavbar } = useNavbarPosition()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// webview refs(池内部自用,用于控制显示/隐藏)
|
||||||
|
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||||
|
|
||||||
|
// 使用集中工具进行更稳健的路由判断
|
||||||
|
const isAppDetail = (() => {
|
||||||
|
const pathname = location.pathname
|
||||||
|
if (pathname === '/apps') return false
|
||||||
|
if (!pathname.startsWith('/apps/')) return false
|
||||||
|
const parts = pathname.split('/').filter(Boolean) // ['apps', '<id>', ...]
|
||||||
|
return parts.length >= 2
|
||||||
|
})()
|
||||||
|
const shouldShow = isTopNavbar && isAppDetail
|
||||||
|
|
||||||
|
// 组合当前需要渲染的列表(保持顺序即可)
|
||||||
|
const apps = openedKeepAliveMinapps
|
||||||
|
|
||||||
|
/** 设置 ref 回调 */
|
||||||
|
const handleSetRef = (appid: string, el: WebviewTag | null) => {
|
||||||
|
if (el) {
|
||||||
|
webviewRefs.current.set(appid, el)
|
||||||
|
} else {
|
||||||
|
webviewRefs.current.delete(appid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebView 加载完成回调 */
|
||||||
|
const handleLoaded = (appid: string) => {
|
||||||
|
setWebviewLoaded(appid, true)
|
||||||
|
logger.debug(`TabPool webview loaded: ${appid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录导航(暂未外曝 URL 状态,后续可接入全局 URL Map) */
|
||||||
|
const handleNavigate = (appid: string, url: string) => {
|
||||||
|
logger.debug(`TabPool webview navigate: ${appid} -> ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换显示状态:仅当前 active 的显示,其余隐藏 */
|
||||||
|
useEffect(() => {
|
||||||
|
webviewRefs.current.forEach((ref, id) => {
|
||||||
|
if (!ref) return
|
||||||
|
const active = id === currentMinappId && shouldShow
|
||||||
|
ref.style.display = active ? 'inline-flex' : 'none'
|
||||||
|
})
|
||||||
|
}, [currentMinappId, shouldShow, apps.length])
|
||||||
|
|
||||||
|
/** 当某个已在 Map 里但不再属于 openedKeepAlive 时,移除引用(React 自身会卸载元素) */
|
||||||
|
useEffect(() => {
|
||||||
|
const existing = Array.from(webviewRefs.current.keys())
|
||||||
|
existing.forEach((id) => {
|
||||||
|
if (!apps.find((a) => a.id === id)) {
|
||||||
|
webviewRefs.current.delete(id)
|
||||||
|
// loaded 状态也清理(LRU 已在其它地方清除,双保险)
|
||||||
|
if (getWebviewLoaded(id)) {
|
||||||
|
setWebviewLoaded(id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [apps])
|
||||||
|
|
||||||
|
// 不显示时直接 hidden,避免闪烁;仍然保留 DOM 做保活
|
||||||
|
const toolbarHeight = 35 // 与 MinimalToolbar 高度保持一致
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PoolContainer
|
||||||
|
style={
|
||||||
|
shouldShow
|
||||||
|
? {
|
||||||
|
visibility: 'visible',
|
||||||
|
top: toolbarHeight,
|
||||||
|
height: `calc(100% - ${toolbarHeight}px)`
|
||||||
|
}
|
||||||
|
: { visibility: 'hidden' }
|
||||||
|
}
|
||||||
|
data-minapp-tabs-pool
|
||||||
|
aria-hidden={!shouldShow}>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<WebviewWrapper key={app.id} $active={app.id === currentMinappId}>
|
||||||
|
<WebviewContainer
|
||||||
|
appid={app.id}
|
||||||
|
url={app.url}
|
||||||
|
onSetRefCallback={handleSetRef}
|
||||||
|
onLoadedCallback={handleLoaded}
|
||||||
|
onNavigateCallback={handleNavigate}
|
||||||
|
/>
|
||||||
|
</WebviewWrapper>
|
||||||
|
))}
|
||||||
|
</PoolContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PoolContainer = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
/* top 在运行时通过 style 注入 (toolbarHeight) */
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
& webview {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const WebviewWrapper = styled.div<{ $active: boolean }>`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* display 控制在内部 webview 元素上做,这里保持结构稳定 */
|
||||||
|
pointer-events: ${(props) => (props.$active ? 'auto' : 'none')};
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MinAppTabsPool
|
||||||
@@ -24,6 +24,7 @@ import { useAppDispatch } from '@renderer/store'
|
|||||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
import { delay } from '@renderer/utils'
|
import { delay } from '@renderer/utils'
|
||||||
|
import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||||
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
|
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
|
||||||
import { WebviewTag } from 'electron'
|
import { WebviewTag } from 'electron'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 */
|
/** store the webview refs, one of the key to make them keepalive */
|
||||||
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||||
/** indicate whether the webview has loaded */
|
/** Note: WebView loaded states now managed globally via webviewStateManager */
|
||||||
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
|
|
||||||
/** whether the minapps open link external is enabled */
|
/** whether the minapps open link external is enabled */
|
||||||
const { minappsOpenLinkExternal } = useSettings()
|
const { minappsOpenLinkExternal } = useSettings()
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
|
|
||||||
setIsPopupShow(true)
|
setIsPopupShow(true)
|
||||||
|
|
||||||
if (webviewLoadedRefs.current.get(currentMinappId)) {
|
if (getWebviewLoaded(currentMinappId)) {
|
||||||
setIsReady(true)
|
setIsReady(true)
|
||||||
/** the case that open the minapp from sidebar */
|
/** the case that open the minapp from sidebar */
|
||||||
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
|
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
|
||||||
@@ -216,17 +216,21 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
|
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
|
||||||
})
|
})
|
||||||
|
|
||||||
//delete the extra webviewLoadedRefs
|
// Set external link behavior for current minapp
|
||||||
webviewLoadedRefs.current.forEach((_, appid) => {
|
if (currentMinappId) {
|
||||||
if (!webviewRefs.current.has(appid)) {
|
const webviewElement = webviewRefs.current.get(currentMinappId)
|
||||||
webviewLoadedRefs.current.delete(appid)
|
if (webviewElement) {
|
||||||
} else if (appid === currentMinappId) {
|
try {
|
||||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
const webviewId = webviewElement.getWebContentsId()
|
||||||
if (webviewId) {
|
if (webviewId) {
|
||||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
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])
|
}, [currentMinappId, minappsOpenLinkExternal])
|
||||||
|
|
||||||
/** only the keepalive minapp can be minimized */
|
/** only the keepalive minapp can be minimized */
|
||||||
@@ -255,15 +259,17 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
/** get the current app info with extra info */
|
/** get the current app info with extra info */
|
||||||
let currentAppInfo: AppInfo | null = null
|
let currentAppInfo: AppInfo | null = null
|
||||||
if (currentMinappId) {
|
if (currentMinappId) {
|
||||||
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
|
const currentApp = combinedApps.find((item) => item.id === currentMinappId)
|
||||||
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
if (currentApp) {
|
||||||
|
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** will close the popup and delete the webview */
|
/** will close the popup and delete the webview */
|
||||||
const handlePopupClose = async (appid: string) => {
|
const handlePopupClose = async (appid: string) => {
|
||||||
setIsPopupShow(false)
|
setIsPopupShow(false)
|
||||||
await delay(0.3)
|
await delay(0.3)
|
||||||
webviewLoadedRefs.current.delete(appid)
|
clearWebviewState(appid)
|
||||||
closeMinapp(appid)
|
closeMinapp(appid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,10 +298,17 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
|
|
||||||
/** the callback function to set the webviews loaded indicator */
|
/** the callback function to set the webviews loaded indicator */
|
||||||
const handleWebviewLoaded = (appid: string) => {
|
const handleWebviewLoaded = (appid: string) => {
|
||||||
webviewLoadedRefs.current.set(appid, true)
|
setWebviewLoaded(appid, true)
|
||||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
const webviewElement = webviewRefs.current.get(appid)
|
||||||
if (webviewId) {
|
if (webviewElement) {
|
||||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
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) {
|
if (appid == currentMinappId) {
|
||||||
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
|
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
|
||||||
@@ -352,16 +365,28 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
/** navigate back in webview history */
|
/** navigate back in webview history */
|
||||||
const handleGoBack = (appid: string) => {
|
const handleGoBack = (appid: string) => {
|
||||||
const webview = webviewRefs.current.get(appid)
|
const webview = webviewRefs.current.get(appid)
|
||||||
if (webview && webview.canGoBack()) {
|
if (webview) {
|
||||||
webview.goBack()
|
try {
|
||||||
|
if (webview.canGoBack()) {
|
||||||
|
webview.goBack()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`WebView ${appid} not ready for goBack()`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** navigate forward in webview history */
|
/** navigate forward in webview history */
|
||||||
const handleGoForward = (appid: string) => {
|
const handleGoForward = (appid: string) => {
|
||||||
const webview = webviewRefs.current.get(appid)
|
const webview = webviewRefs.current.get(appid)
|
||||||
if (webview && webview.canGoForward()) {
|
if (webview) {
|
||||||
webview.goForward()
|
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>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
|
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavbar={isTopNavbar}>
|
||||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||||
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
|
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
|
||||||
<ArrowLeftOutlined />
|
<ArrowLeftOutlined />
|
||||||
@@ -498,19 +523,25 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
|
title={isTopNavbar ? null : <Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
onClose={handlePopupMinimize}
|
onClose={handlePopupMinimize}
|
||||||
open={isPopupShow}
|
open={isPopupShow}
|
||||||
mask={false}
|
mask={false}
|
||||||
rootClassName="minapp-drawer"
|
rootClassName="minapp-drawer"
|
||||||
maskClassName="minapp-mask"
|
maskClassName="minapp-mask"
|
||||||
height={'100%'}
|
height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
style={{
|
styles={{
|
||||||
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
|
wrapper: {
|
||||||
backgroundColor: window.root.style.background
|
position: 'fixed',
|
||||||
|
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
|
||||||
|
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
backgroundColor: window.root.style.background
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||||
@@ -566,7 +597,7 @@ const TitleTextTooltip = styled.span`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const ButtonsGroup = styled.div`
|
const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
|
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||||
|
|
||||||
const TopViewMinappContainer = () => {
|
const TopViewMinappContainer = () => {
|
||||||
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
|
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
|
||||||
|
const { isLeftNavbar } = useNavbarPosition()
|
||||||
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
|
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
|
export default TopViewMinappContainer
|
||||||
|
|||||||
@@ -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 { WebviewTag } from 'electron'
|
||||||
import { memo, useEffect, useRef } from 'react'
|
import { memo, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('WebviewContainer')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebviewContainer is a component that renders a webview element.
|
* WebviewContainer is a component that renders a webview element.
|
||||||
* It is used in the MinAppPopupContainer component.
|
* It is used in the MinAppPopupContainer component.
|
||||||
@@ -23,7 +26,6 @@ const WebviewContainer = memo(
|
|||||||
}) => {
|
}) => {
|
||||||
const webviewRef = useRef<WebviewTag | null>(null)
|
const webviewRef = useRef<WebviewTag | null>(null)
|
||||||
const { enableSpellCheck } = useSettings()
|
const { enableSpellCheck } = useSettings()
|
||||||
const { isLeftNavbar } = useNavbarPosition()
|
|
||||||
|
|
||||||
const setRef = (appid: string) => {
|
const setRef = (appid: string) => {
|
||||||
onSetRefCallback(appid, null)
|
onSetRefCallback(appid, null)
|
||||||
@@ -41,8 +43,29 @@ const WebviewContainer = memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webviewRef.current) return
|
if (!webviewRef.current) return
|
||||||
|
|
||||||
|
let loadCallbackFired = false
|
||||||
|
|
||||||
const handleLoaded = () => {
|
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) => {
|
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('dom-ready', handleDomReady)
|
||||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||||
|
webviewRef.current.addEventListener('ready-to-show', handleReadyToShow)
|
||||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||||
|
|
||||||
// we set the url when the webview is ready
|
// we set the url when the webview is ready
|
||||||
webviewRef.current.src = url
|
webviewRef.current.src = url
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading)
|
||||||
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
|
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
|
||||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||||
|
webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow)
|
||||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||||
}
|
}
|
||||||
// because the appid and url are enough, no need to add onLoadedCallback
|
// because the appid and url are enough, no need to add onLoadedCallback
|
||||||
@@ -73,8 +105,8 @@ const WebviewContainer = memo(
|
|||||||
}, [appid, url])
|
}, [appid, url])
|
||||||
|
|
||||||
const WebviewStyle: React.CSSProperties = {
|
const WebviewStyle: React.CSSProperties = {
|
||||||
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
|
width: '100%',
|
||||||
height: 'calc(100vh - var(--navbar-height))',
|
height: '100%',
|
||||||
backgroundColor: 'var(--color-background)',
|
backgroundColor: 'var(--color-background)',
|
||||||
display: 'inline-flex'
|
display: 'inline-flex'
|
||||||
}
|
}
|
||||||
@@ -83,6 +115,7 @@ const WebviewContainer = memo(
|
|||||||
<webview
|
<webview
|
||||||
key={appid}
|
key={appid}
|
||||||
ref={setRef(appid)}
|
ref={setRef(appid)}
|
||||||
|
data-minapp-id={appid}
|
||||||
style={WebviewStyle}
|
style={WebviewStyle}
|
||||||
allowpopups={'true' as any}
|
allowpopups={'true' as any}
|
||||||
partition="persist:webview"
|
partition="persist:webview"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import CherryLogo from '@renderer/assets/images/banner.png'
|
|
||||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||||
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
|
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
|
||||||
import { Skeleton, Typography } from 'antd'
|
import { Skeleton, Typography } from 'antd'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
const { Title, Paragraph } = Typography
|
const { Title, Paragraph } = Typography
|
||||||
|
|
||||||
@@ -11,6 +10,8 @@ type Props = {
|
|||||||
show: boolean
|
show: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_HEIGHT = '9rem' // equals h-36
|
||||||
|
|
||||||
export const OGCard = ({ link, show }: Props) => {
|
export const OGCard = ({ link, show }: Props) => {
|
||||||
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
|
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
|
||||||
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
|
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
|
||||||
@@ -32,6 +33,14 @@ export const OGCard = ({ link, show }: Props) => {
|
|||||||
}
|
}
|
||||||
}, [parseMetadata, isLoading, show])
|
}, [parseMetadata, isLoading, show])
|
||||||
|
|
||||||
|
const GeneratedGraph = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-36 items-center justify-center bg-accent p-4">
|
||||||
|
<h2 className="text-2xl font-bold">{metadata['og:title'] || hostname}</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [hostname, metadata])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <CardSkeleton />
|
return <CardSkeleton />
|
||||||
}
|
}
|
||||||
@@ -45,7 +54,7 @@ export const OGCard = ({ link, show }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{!hasImage && (
|
{!hasImage && (
|
||||||
<PreviewImageContainer>
|
<PreviewImageContainer>
|
||||||
<PreviewImage src={CherryLogo} alt={'no image'} />
|
<GeneratedGraph />
|
||||||
</PreviewImageContainer>
|
</PreviewImageContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -113,8 +122,8 @@ const PreviewContainer = styled.div<{ hasImage?: boolean }>`
|
|||||||
|
|
||||||
const PreviewImageContainer = styled.div`
|
const PreviewImageContainer = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: ${IMAGE_HEIGHT};
|
||||||
min-height: 140px;
|
min-height: ${IMAGE_HEIGHT};
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -128,7 +137,7 @@ const PreviewContent = styled.div`
|
|||||||
|
|
||||||
const PreviewImage = styled.img`
|
const PreviewImage = styled.img`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: ${IMAGE_HEIGHT};
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
144
src/renderer/src/components/ProviderAvatar.tsx
Normal file
144
src/renderer/src/components/ProviderAvatar.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { PoeLogo } from '@renderer/components/Icons'
|
||||||
|
import { getProviderLogo } from '@renderer/config/providers'
|
||||||
|
import { Provider } from '@renderer/types'
|
||||||
|
import { generateColorFromChar, getFirstCharacter, getForegroundColor } from '@renderer/utils'
|
||||||
|
import { Avatar } from 'antd'
|
||||||
|
import React from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface ProviderAvatarPrimitiveProps {
|
||||||
|
providerId: string
|
||||||
|
providerName: string
|
||||||
|
logoSrc?: string
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderAvatarProps {
|
||||||
|
provider: Provider
|
||||||
|
customLogos?: Record<string, string>
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderSvgLogo = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0.5px solid var(--color-border);
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ProviderLogo = styled(Avatar)`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0.5px solid var(--color-border);
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ProviderAvatarPrimitive: React.FC<ProviderAvatarPrimitiveProps> = ({
|
||||||
|
providerId,
|
||||||
|
providerName,
|
||||||
|
logoSrc,
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
if (providerId === 'poe') {
|
||||||
|
return (
|
||||||
|
<ProviderSvgLogo className={className} style={style}>
|
||||||
|
<PoeLogo fontSize={size} />
|
||||||
|
</ProviderSvgLogo>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoSrc) {
|
||||||
|
return (
|
||||||
|
<ProviderLogo draggable="false" shape="circle" src={logoSrc} className={className} style={style} size={size} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundColor = generateColorFromChar(providerName)
|
||||||
|
const color = providerName ? getForegroundColor(backgroundColor) : 'white'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProviderLogo
|
||||||
|
size={size}
|
||||||
|
shape="circle"
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
backgroundColor,
|
||||||
|
color,
|
||||||
|
...style
|
||||||
|
}}>
|
||||||
|
{getFirstCharacter(providerName)}
|
||||||
|
</ProviderLogo>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProviderAvatar: React.FC<ProviderAvatarProps> = ({
|
||||||
|
provider,
|
||||||
|
customLogos = {},
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
size
|
||||||
|
}) => {
|
||||||
|
const systemLogoSrc = getProviderLogo(provider.id)
|
||||||
|
if (systemLogoSrc) {
|
||||||
|
return (
|
||||||
|
<ProviderAvatarPrimitive
|
||||||
|
size={size}
|
||||||
|
providerId={provider.id}
|
||||||
|
providerName={provider.name}
|
||||||
|
logoSrc={systemLogoSrc}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const customLogo = customLogos[provider.id]
|
||||||
|
if (customLogo) {
|
||||||
|
if (customLogo === 'poe') {
|
||||||
|
return (
|
||||||
|
<ProviderAvatarPrimitive
|
||||||
|
size={size}
|
||||||
|
providerId="poe"
|
||||||
|
providerName={provider.name}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProviderAvatarPrimitive
|
||||||
|
providerId={provider.id}
|
||||||
|
providerName={provider.name}
|
||||||
|
logoSrc={customLogo}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProviderAvatarPrimitive
|
||||||
|
providerId={provider.id}
|
||||||
|
providerName={provider.name}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SearchOutlined } from '@ant-design/icons'
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
|
||||||
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
|
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
import { getProviderLabel } from '@renderer/i18n/label'
|
||||||
import { Input, Tooltip } from 'antd'
|
import { Input, Tooltip } from 'antd'
|
||||||
@@ -48,10 +49,10 @@ const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
|
|||||||
/>
|
/>
|
||||||
</SearchContainer>
|
</SearchContainer>
|
||||||
<LogoGrid>
|
<LogoGrid>
|
||||||
{filteredProviders.map(({ id, logo, name }) => (
|
{filteredProviders.map(({ id, name, logo }) => (
|
||||||
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
|
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
|
||||||
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
|
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
|
||||||
<img src={logo} alt={name} draggable={false} />
|
<ProviderAvatarPrimitive providerId={id} size={52} providerName={name} logoSrc={logo} />
|
||||||
</LogoItem>
|
</LogoItem>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
@@ -86,11 +87,12 @@ const LogoGrid = styled.div`
|
|||||||
const LogoItem = styled.div`
|
const LogoItem = styled.div`
|
||||||
width: 52px;
|
width: 52px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
|
border-radius: 100%;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
background: var(--color-background-soft);
|
background: var(--color-background-soft);
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
@@ -102,8 +104,8 @@ const LogoItem = styled.div`
|
|||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 32px;
|
width: 100%;
|
||||||
height: 32px;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons'
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
|
|
||||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||||
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
|
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
|
||||||
import tabsService from '@renderer/services/TabsService'
|
import tabsService from '@renderer/services/TabsService'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
@@ -37,11 +38,23 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import MinAppIcon from '../Icons/MinAppIcon'
|
||||||
|
import MinAppTabsPool from '../MinApp/MinAppTabsPool'
|
||||||
|
|
||||||
interface TabsContainerProps {
|
interface TabsContainerProps {
|
||||||
children: React.ReactNode
|
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) {
|
switch (tabId) {
|
||||||
case 'home':
|
case 'home':
|
||||||
return <Home size={14} />
|
return <Home size={14} />
|
||||||
@@ -82,6 +95,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
const isFullscreen = useFullscreen()
|
const isFullscreen = useFullscreen()
|
||||||
const { settedTheme, toggleTheme } = useTheme()
|
const { settedTheme, toggleTheme } = useTheme()
|
||||||
const { hideMinappPopup } = useMinappPopup()
|
const { hideMinappPopup } = useMinappPopup()
|
||||||
|
const { minapps } = useMinapps()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [canScroll, setCanScroll] = useState(false)
|
const [canScroll, setCanScroll] = useState(false)
|
||||||
@@ -89,9 +103,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
const getTabId = (path: string): string => {
|
const getTabId = (path: string): string => {
|
||||||
if (path === '/') return 'home'
|
if (path === '/') return 'home'
|
||||||
const segments = path.split('/')
|
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
|
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) => {
|
const shouldCreateTab = (path: string) => {
|
||||||
if (path === '/') return false
|
if (path === '/') return false
|
||||||
if (path === '/settings') return false
|
if (path === '/settings') return false
|
||||||
@@ -196,8 +224,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
renderItem={(tab) => (
|
renderItem={(tab) => (
|
||||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||||
<TabHeader>
|
<TabHeader>
|
||||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||||
</TabHeader>
|
</TabHeader>
|
||||||
{tab.id !== 'home' && (
|
{tab.id !== 'home' && (
|
||||||
<CloseButton
|
<CloseButton
|
||||||
@@ -224,7 +252,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
</AddTabButton>
|
</AddTabButton>
|
||||||
</TabsArea>
|
</TabsArea>
|
||||||
<RightButtonsContainer>
|
<RightButtonsContainer>
|
||||||
<TopNavbarOpenedMinappTabs />
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
|
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
|
||||||
mouseEnterDelay={0.8}
|
mouseEnterDelay={0.8}
|
||||||
@@ -244,7 +271,11 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
</SettingsButton>
|
</SettingsButton>
|
||||||
</RightButtonsContainer>
|
</RightButtonsContainer>
|
||||||
</TabsBar>
|
</TabsBar>
|
||||||
<TabContent>{children}</TabContent>
|
<TabContent>
|
||||||
|
{/* MiniApp WebView 池(Tab 模式保活) */}
|
||||||
|
<MinAppTabsPool />
|
||||||
|
{children}
|
||||||
|
</TabContent>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -443,6 +474,7 @@ const TabContent = styled.div`
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative; /* 约束 MinAppTabsPool 绝对定位范围 */
|
||||||
`
|
`
|
||||||
|
|
||||||
export default TabsContainer
|
export default TabsContainer
|
||||||
|
|||||||
@@ -6,107 +6,13 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
|||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { Dropdown, Tooltip } from 'antd'
|
import { Dropdown, Tooltip } from 'antd'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { DraggableList } from '../DraggableList'
|
import { DraggableList } from '../DraggableList'
|
||||||
import MinAppIcon from '../Icons/MinAppIcon'
|
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 */
|
/** Tabs of opened minapps in sidebar */
|
||||||
export const SidebarOpenedMinappTabs: FC = () => {
|
export const SidebarOpenedMinappTabs: FC = () => {
|
||||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||||
@@ -116,7 +22,7 @@ export const SidebarOpenedMinappTabs: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isLeftNavbar } = useNavbarPosition()
|
const { isLeftNavbar } = useNavbarPosition()
|
||||||
|
|
||||||
const handleOnClick = (app) => {
|
const handleOnClick = (app: MinAppType) => {
|
||||||
if (minappShow && currentMinappId === app.id) {
|
if (minappShow && currentMinappId === app.id) {
|
||||||
hideMinappPopup()
|
hideMinappPopup()
|
||||||
} else {
|
} else {
|
||||||
@@ -329,50 +235,3 @@ const TabsWrapper = styled.div`
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
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;
|
|
||||||
`
|
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
|||||||
vertexai: VertexAIProviderLogo,
|
vertexai: VertexAIProviderLogo,
|
||||||
'new-api': NewAPIProviderLogo,
|
'new-api': NewAPIProviderLogo,
|
||||||
'aws-bedrock': AwsProviderLogo,
|
'aws-bedrock': AwsProviderLogo,
|
||||||
poe: 'svg' // use svg icon component
|
poe: 'poe' // use svg icon component
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function getProviderLogo(providerId: string) {
|
export function getProviderLogo(providerId: string) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useEffect } from 'react'
|
|||||||
import { useDefaultModel } from './useAssistant'
|
import { useDefaultModel } from './useAssistant'
|
||||||
import useFullScreenNotice from './useFullScreenNotice'
|
import useFullScreenNotice from './useFullScreenNotice'
|
||||||
import { useRuntime } from './useRuntime'
|
import { useRuntime } from './useRuntime'
|
||||||
import { useSettings } from './useSettings'
|
import { useNavbarPosition, useSettings } from './useSettings'
|
||||||
import useUpdateHandler from './useUpdateHandler'
|
import useUpdateHandler from './useUpdateHandler'
|
||||||
|
|
||||||
const logger = loggerService.withContext('useAppInit')
|
const logger = loggerService.withContext('useAppInit')
|
||||||
@@ -37,6 +37,7 @@ export function useAppInit() {
|
|||||||
customCss,
|
customCss,
|
||||||
enableDataCollection
|
enableDataCollection
|
||||||
} = useSettings()
|
} = useSettings()
|
||||||
|
const { isLeftNavbar } = useNavbarPosition()
|
||||||
const { minappShow } = useRuntime()
|
const { minappShow } = useRuntime()
|
||||||
const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel()
|
const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel()
|
||||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||||
@@ -100,16 +101,15 @@ export function useAppInit() {
|
|||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
|
const isMacTransparentWindow = windowStyle === 'transparent' && isMac
|
||||||
|
|
||||||
if (minappShow) {
|
if (minappShow && isLeftNavbar) {
|
||||||
window.root.style.background =
|
window.root.style.background = isMacTransparentWindow ? 'var(--color-background)' : 'var(--navbar-background)'
|
||||||
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
|
window.root.style.background = isMacTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
|
||||||
}, [windowStyle, minappShow, theme])
|
}, [windowStyle, minappShow, theme, isLeftNavbar])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLocalAi) {
|
if (isLocalAi) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||||
|
import TabsService from '@renderer/services/TabsService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import {
|
import {
|
||||||
setCurrentMinappId,
|
setCurrentMinappId,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
setOpenedOneOffMinapp
|
setOpenedOneOffMinapp
|
||||||
} from '@renderer/store/runtime'
|
} from '@renderer/store/runtime'
|
||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
|
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
@@ -36,7 +38,18 @@ export const useMinappPopup = () => {
|
|||||||
const createLRUCache = useCallback(() => {
|
const createLRUCache = useCallback(() => {
|
||||||
return new LRUCache<string, MinAppType>({
|
return new LRUCache<string, MinAppType>({
|
||||||
max: maxKeepAliveMinapps,
|
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())))
|
dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values())))
|
||||||
},
|
},
|
||||||
onInsert: () => {
|
onInsert: () => {
|
||||||
@@ -158,6 +171,8 @@ export const useMinappPopup = () => {
|
|||||||
openMinappById,
|
openMinappById,
|
||||||
closeMinapp,
|
closeMinapp,
|
||||||
hideMinappPopup,
|
hideMinappPopup,
|
||||||
closeAllMinapps
|
closeAllMinapps,
|
||||||
|
// Expose cache instance for TabsService integration
|
||||||
|
minAppsCache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const Alert = styled(AntdAlert)`
|
|||||||
margin: 0.5rem 0 !important;
|
margin: 0.5rem 0 !important;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
align-items: center;
|
||||||
& .ant-alert-close-icon {
|
& .ant-alert-close-icon {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const Container = styled.div<{ $isDark: boolean }>`
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
margin: 15px 24px;
|
margin: 15px 20px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import App from '@renderer/components/MinApp/MinApp'
|
|||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
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 { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
|
||||||
import { FC, useMemo } from 'react'
|
import { FC, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -105,7 +104,7 @@ const LaunchpadPage: FC = () => {
|
|||||||
<Grid>
|
<Grid>
|
||||||
{sortedMinapps.map((app) => (
|
{sortedMinapps.map((app) => (
|
||||||
<AppWrapper key={app.id}>
|
<AppWrapper key={app.id}>
|
||||||
<App app={app} size={56} onClick={() => setTimeout(() => tabsService.closeTab('launchpad'), 350)} />
|
<App app={app} size={56} />
|
||||||
</AppWrapper>
|
</AppWrapper>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
216
src/renderer/src/pages/minapps/MinAppPage.tsx
Normal file
216
src/renderer/src/pages/minapps/MinAppPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
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 { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||||
|
import TabsService from '@renderer/services/TabsService'
|
||||||
|
import { getWebviewLoaded, onWebviewStateChange, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||||
|
import { Avatar } from 'antd'
|
||||||
|
import { WebviewTag } from 'electron'
|
||||||
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import BeatLoader from 'react-spinners/BeatLoader'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
||||||
|
import MinimalToolbar from './components/MinimalToolbar'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('MinAppPage')
|
||||||
|
|
||||||
|
const MinAppPage: FC = () => {
|
||||||
|
const { appId } = useParams<{ appId: string }>()
|
||||||
|
const { isTopNavbar } = useNavbarPosition()
|
||||||
|
const { openMinappKeepAlive, minAppsCache } = useMinappPopup()
|
||||||
|
const { minapps } = useMinapps()
|
||||||
|
// openedKeepAliveMinapps 不再需要作为依赖参与 webview 选择,已通过 MutationObserver 动态发现
|
||||||
|
// 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) {
|
||||||
|
// 无论是否已在缓存,都调用以确保 currentMinappId 同步到路由切换的新 appId
|
||||||
|
openMinappKeepAlive(app)
|
||||||
|
}
|
||||||
|
}, [app, navigate, openMinappKeepAlive, initialIsTopNavbar])
|
||||||
|
|
||||||
|
// -------------- 新的 Tab Shell 逻辑 --------------
|
||||||
|
// 注意:Hooks 必须在任何 return 之前调用,因此提前定义,并在内部判空
|
||||||
|
const webviewRef = useRef<WebviewTag | null>(null)
|
||||||
|
const [isReady, setIsReady] = useState<boolean>(() => (app ? getWebviewLoaded(app.id) : false))
|
||||||
|
const [currentUrl, setCurrentUrl] = useState<string | null>(app?.url ?? null)
|
||||||
|
|
||||||
|
// 获取池中的 webview 元素(避免因为 openedKeepAliveMinapps.length 变化而频繁重跑)
|
||||||
|
const webviewCleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
const attachWebview = useCallback(() => {
|
||||||
|
if (!app) return true // 没有 app 不再继续监控
|
||||||
|
const selector = `webview[data-minapp-id="${app.id}"]`
|
||||||
|
const el = document.querySelector(selector) as WebviewTag | null
|
||||||
|
if (!el) return false
|
||||||
|
|
||||||
|
if (webviewRef.current === el) return true // 已附着
|
||||||
|
|
||||||
|
webviewRef.current = el
|
||||||
|
const handleInPageNav = (e: any) => setCurrentUrl(e.url)
|
||||||
|
el.addEventListener('did-navigate-in-page', handleInPageNav)
|
||||||
|
webviewCleanupRef.current = () => {
|
||||||
|
el.removeEventListener('did-navigate-in-page', handleInPageNav)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, [app])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!app) return
|
||||||
|
|
||||||
|
// 先尝试立即附着
|
||||||
|
if (attachWebview()) return () => webviewCleanupRef.current?.()
|
||||||
|
|
||||||
|
// 若尚未创建,对 DOM 变更做一次监听(轻量 + 自动断开)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (attachWebview()) {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
webviewCleanupRef.current?.()
|
||||||
|
}
|
||||||
|
}, [app, attachWebview])
|
||||||
|
|
||||||
|
// 事件驱动等待加载完成(移除固定 150ms 轮询)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!app) return
|
||||||
|
if (getWebviewLoaded(app.id)) {
|
||||||
|
// 已经加载
|
||||||
|
if (!isReady) setIsReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let mounted = true
|
||||||
|
const unsubscribe = onWebviewStateChange(app.id, (loaded) => {
|
||||||
|
if (!mounted) return
|
||||||
|
if (loaded) {
|
||||||
|
setIsReady(true)
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [app, isReady])
|
||||||
|
|
||||||
|
// 如果条件不满足,提前返回(所有 hooks 已调用)
|
||||||
|
if (!app || !initialIsTopNavbar.current) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
if (!app) return
|
||||||
|
if (webviewRef.current) {
|
||||||
|
setWebviewLoaded(app.id, false)
|
||||||
|
setIsReady(false)
|
||||||
|
webviewRef.current.src = app.url
|
||||||
|
setCurrentUrl(app.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenDevTools = () => {
|
||||||
|
webviewRef.current?.openDevTools()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShellContainer>
|
||||||
|
<ToolbarWrapper>
|
||||||
|
<MinimalToolbar
|
||||||
|
app={app}
|
||||||
|
webviewRef={webviewRef}
|
||||||
|
// currentUrl 可能为 null(尚未捕获导航),外部打开时会 fallback 到 app.url
|
||||||
|
currentUrl={currentUrl}
|
||||||
|
onReload={handleReload}
|
||||||
|
onOpenDevTools={handleOpenDevTools}
|
||||||
|
/>
|
||||||
|
</ToolbarWrapper>
|
||||||
|
{!isReady && (
|
||||||
|
<LoadingMask>
|
||||||
|
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
||||||
|
<BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} />
|
||||||
|
</LoadingMask>
|
||||||
|
)}
|
||||||
|
</ShellContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const ShellContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 3; /* 高于池中的 webview */
|
||||||
|
pointer-events: none; /* 让下层 webview 默认可交互 */
|
||||||
|
> * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ToolbarWrapper = styled.div`
|
||||||
|
flex-shrink: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const LoadingMask = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
inset: 35px 0 0 0; /* 避开 toolbar 高度 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* 垂直堆叠 */
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-background);
|
||||||
|
z-index: 4;
|
||||||
|
gap: 12px;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MinAppPage
|
||||||
166
src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx
Normal file
166
src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx
Normal 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
|
||||||
218
src/renderer/src/pages/minapps/components/MinimalToolbar.tsx
Normal file
218
src/renderer/src/pages/minapps/components/MinimalToolbar.tsx
Normal 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
|
||||||
@@ -563,8 +563,10 @@ const NotesPage: FC = () => {
|
|||||||
const handleMoveNode = useCallback(
|
const handleMoveNode = useCallback(
|
||||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||||
try {
|
try {
|
||||||
await moveNode(sourceNodeId, targetNodeId, position)
|
const result = await moveNode(sourceNodeId, targetNodeId, position)
|
||||||
await sortAllLevels(sortType)
|
if (result.success && result.type !== 'manual_reorder') {
|
||||||
|
await sortAllLevels(sortType)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to move nodes:', error as Error)
|
logger.error('Failed to move nodes:', error as Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
import { nanoid } from '@reduxjs/toolkit'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
|
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
|
||||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||||
@@ -23,6 +24,8 @@ import McpMarketList from './McpMarketList'
|
|||||||
import McpServerCard from './McpServerCard'
|
import McpServerCard from './McpServerCard'
|
||||||
import SyncServersPopup from './SyncServersPopup'
|
import SyncServersPopup from './SyncServersPopup'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('McpServersList')
|
||||||
|
|
||||||
const McpServersList: FC = () => {
|
const McpServersList: FC = () => {
|
||||||
const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
|
const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -158,12 +161,11 @@ const McpServersList: FC = () => {
|
|||||||
const handleToggleActive = async (server: MCPServer, active: boolean) => {
|
const handleToggleActive = async (server: MCPServer, active: boolean) => {
|
||||||
setLoadingServerIds((prev) => new Set(prev).add(server.id))
|
setLoadingServerIds((prev) => new Set(prev).add(server.id))
|
||||||
const oldActiveState = server.isActive
|
const oldActiveState = server.isActive
|
||||||
|
logger.silly('toggle activate', { serverId: server.id, active })
|
||||||
try {
|
try {
|
||||||
if (active) {
|
if (active) {
|
||||||
await window.api.mcp.listTools(server)
|
|
||||||
// Fetch version when server is activated
|
// Fetch version when server is activated
|
||||||
fetchServerVersion({ ...server, isActive: active })
|
await fetchServerVersion({ ...server, isActive: active })
|
||||||
} else {
|
} else {
|
||||||
await window.api.mcp.stopServer(server)
|
await window.api.mcp.stopServer(server)
|
||||||
// Clear version when server is deactivated
|
// Clear version when server is deactivated
|
||||||
@@ -259,7 +261,7 @@ const McpServersList: FC = () => {
|
|||||||
server={server}
|
server={server}
|
||||||
version={serverVersions[server.id]}
|
version={serverVersions[server.id]}
|
||||||
isLoading={loadingServerIds.has(server.id)}
|
isLoading={loadingServerIds.has(server.id)}
|
||||||
onToggle={(active) => handleToggleActive(server, active)}
|
onToggle={async (active) => await handleToggleActive(server, active)}
|
||||||
onDelete={() => onDeleteMcpServer(server)}
|
onDelete={() => onDeleteMcpServer(server)}
|
||||||
onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
|
onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
|
||||||
onOpenUrl={(url) => window.open(url, '_blank')}
|
onOpenUrl={(url) => window.open(url, '_blank')}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { Center, VStack } from '@renderer/components/Layout'
|
import { Center, VStack } from '@renderer/components/Layout'
|
||||||
|
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
|
||||||
import ProviderLogoPicker from '@renderer/components/ProviderLogoPicker'
|
import ProviderLogoPicker from '@renderer/components/ProviderLogoPicker'
|
||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
|
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
|
||||||
@@ -143,7 +144,6 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
})
|
})
|
||||||
setLogo(tempUrl)
|
setLogo(tempUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
setDropdownOpen(false)
|
setDropdownOpen(false)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
window.message.error(error.message)
|
window.message.error(error.message)
|
||||||
@@ -152,7 +152,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
<MenuItem ref={uploadRef}>{t('settings.general.image_upload')}</MenuItem>
|
<MenuItem ref={uploadRef}>{t('settings.general.image_upload')}</MenuItem>
|
||||||
</Upload>
|
</Upload>
|
||||||
),
|
),
|
||||||
onClick: () => {
|
onClick: (e: any) => {
|
||||||
|
e.stopPropagation()
|
||||||
uploadRef.current?.click()
|
uploadRef.current?.click()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -215,7 +216,9 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
}}
|
}}
|
||||||
placement="bottom">
|
placement="bottom">
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<ProviderLogo src={logo} />
|
<ProviderLogo>
|
||||||
|
<ProviderAvatarPrimitive providerId={logo} providerName={name} logoSrc={logo} size={60} />
|
||||||
|
</ProviderLogo>
|
||||||
) : (
|
) : (
|
||||||
<ProviderInitialsLogo style={name ? { backgroundColor, color } : undefined}>
|
<ProviderInitialsLogo style={name ? { backgroundColor, color } : undefined}>
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
@@ -258,16 +261,17 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProviderLogo = styled.img`
|
const ProviderLogo = styled.div`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 12px;
|
border-radius: 100%;
|
||||||
object-fit: contain;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
background-color: var(--color-background-soft);
|
|
||||||
padding: 5px;
|
|
||||||
border: 0.5px solid var(--color-border);
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
@@ -277,7 +281,7 @@ const ProviderInitialsLogo = styled.div`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 12px;
|
border-radius: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -5,22 +5,14 @@ import {
|
|||||||
type DraggableVirtualListRef,
|
type DraggableVirtualListRef,
|
||||||
useDraggableReorder
|
useDraggableReorder
|
||||||
} from '@renderer/components/DraggableList'
|
} from '@renderer/components/DraggableList'
|
||||||
import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
|
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
||||||
import { getProviderLogo } from '@renderer/config/providers'
|
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
|
||||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import ImageStorage from '@renderer/services/ImageStorage'
|
import ImageStorage from '@renderer/services/ImageStorage'
|
||||||
import { isSystemProvider, Provider, ProviderType } from '@renderer/types'
|
import { isSystemProvider, Provider, ProviderType } from '@renderer/types'
|
||||||
import {
|
import { getFancyProviderName, matchKeywordsInModel, matchKeywordsInProvider, uuid } from '@renderer/utils'
|
||||||
generateColorFromChar,
|
import { Button, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||||
getFancyProviderName,
|
|
||||||
getFirstCharacter,
|
|
||||||
getForegroundColor,
|
|
||||||
matchKeywordsInModel,
|
|
||||||
matchKeywordsInProvider,
|
|
||||||
uuid
|
|
||||||
} from '@renderer/utils'
|
|
||||||
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
|
|
||||||
import { GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
import { GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
||||||
import { FC, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
import { FC, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -280,36 +272,6 @@ const ProviderList: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProviderAvatar = (provider: Provider, size: number = 25) => {
|
|
||||||
// 特殊处理一下svg格式
|
|
||||||
if (isSystemProvider(provider)) {
|
|
||||||
switch (provider.id) {
|
|
||||||
case 'poe':
|
|
||||||
return <PoeLogo fontSize={size} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logoSrc = getProviderLogo(provider.id)
|
|
||||||
if (logoSrc) {
|
|
||||||
return <ProviderLogo draggable="false" shape="circle" src={logoSrc} size={size} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const customLogo = providerLogos[provider.id]
|
|
||||||
if (customLogo) {
|
|
||||||
return <ProviderLogo draggable="false" shape="square" src={customLogo} size={size} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate color for custom provider
|
|
||||||
const backgroundColor = generateColorFromChar(provider.name)
|
|
||||||
const color = provider.name ? getForegroundColor(backgroundColor) : 'white'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProviderLogo size={size} shape="square" style={{ backgroundColor, color, minWidth: size }}>
|
|
||||||
{getFirstCharacter(provider.name)}
|
|
||||||
</ProviderLogo>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredProviders = providers.filter((provider) => {
|
const filteredProviders = providers.filter((provider) => {
|
||||||
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
|
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
const isProviderMatch = matchKeywordsInProvider(keywords, provider)
|
const isProviderMatch = matchKeywordsInProvider(keywords, provider)
|
||||||
@@ -382,7 +344,14 @@ const ProviderList: FC = () => {
|
|||||||
<DragHandle>
|
<DragHandle>
|
||||||
<GripVertical size={12} />
|
<GripVertical size={12} />
|
||||||
</DragHandle>
|
</DragHandle>
|
||||||
{getProviderAvatar(provider)}
|
<ProviderAvatar
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24
|
||||||
|
}}
|
||||||
|
provider={provider}
|
||||||
|
customLogos={providerLogos}
|
||||||
|
/>
|
||||||
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
|
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
|
||||||
{provider.enabled && (
|
{provider.enabled && (
|
||||||
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
|
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
|
||||||
@@ -466,10 +435,6 @@ const DragHandle = styled.div`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const ProviderLogo = styled(Avatar)`
|
|
||||||
border: 0.5px solid var(--color-border);
|
|
||||||
`
|
|
||||||
|
|
||||||
const ProviderItemName = styled.div`
|
const ProviderItemName = styled.div`
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -612,9 +612,14 @@ const TranslatePage: FC = () => {
|
|||||||
// 粘贴上传文件
|
// 粘贴上传文件
|
||||||
const onPaste = useCallback(
|
const onPaste = useCallback(
|
||||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (isProcessing) return
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
logger.debug('event', event)
|
logger.debug('event', event)
|
||||||
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
|
const text = event.clipboardData.getData('text')
|
||||||
|
if (!isEmpty(text)) {
|
||||||
|
setText(text)
|
||||||
|
} else if (event.clipboardData.files && event.clipboardData.files.length > 0) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const files = event.clipboardData.files
|
const files = event.clipboardData.files
|
||||||
const file = getSingleFile(files) as File
|
const file = getSingleFile(files) as File
|
||||||
@@ -659,7 +664,7 @@ const TranslatePage: FC = () => {
|
|||||||
}
|
}
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
},
|
},
|
||||||
[getSingleFile, processFile, t]
|
[getSingleFile, isProcessing, processFile, t]
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const NOTES_TREE_ID = 'notes-tree-structure'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('NotesService')
|
const logger = loggerService.withContext('NotesService')
|
||||||
|
|
||||||
|
export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化/同步笔记树结构
|
* 初始化/同步笔记树结构
|
||||||
*/
|
*/
|
||||||
@@ -182,7 +184,7 @@ export async function moveNode(
|
|||||||
sourceNodeId: string,
|
sourceNodeId: string,
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
position: 'before' | 'after' | 'inside'
|
position: 'before' | 'after' | 'inside'
|
||||||
): Promise<boolean> {
|
): Promise<MoveNodeResult> {
|
||||||
try {
|
try {
|
||||||
const tree = await getNotesTree()
|
const tree = await getNotesTree()
|
||||||
|
|
||||||
@@ -192,19 +194,19 @@ export async function moveNode(
|
|||||||
|
|
||||||
if (!sourceNode || !targetNode) {
|
if (!sourceNode || !targetNode) {
|
||||||
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不允许文件夹被放入文件中
|
// 不允许文件夹被放入文件中
|
||||||
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
|
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
|
||||||
logger.error('Move nodes failed: cannot move a folder inside a file')
|
logger.error('Move nodes failed: cannot move a folder inside a file')
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不允许将节点移动到自身内部
|
// 不允许将节点移动到自身内部
|
||||||
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
|
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
|
||||||
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
|
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetPath: string = ''
|
let targetPath: string = ''
|
||||||
@@ -215,7 +217,7 @@ export async function moveNode(
|
|||||||
targetPath = targetNode.externalPath
|
targetPath = targetNode.externalPath
|
||||||
} else {
|
} else {
|
||||||
logger.error('Cannot move node inside a file node')
|
logger.error('Cannot move node inside a file node')
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const targetParent = findParentNode(tree, targetNodeId)
|
const targetParent = findParentNode(tree, targetNodeId)
|
||||||
@@ -226,6 +228,20 @@ export async function moveNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为同级拖动排序
|
||||||
|
const sourceParent = findParentNode(tree, sourceNodeId)
|
||||||
|
const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!)
|
||||||
|
|
||||||
|
const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath
|
||||||
|
|
||||||
|
if (isSameLevelReorder) {
|
||||||
|
// 同级拖动排序:跳过文件系统操作,只更新树结构
|
||||||
|
logger.debug(`Same level reorder detected, skipping file system operations`)
|
||||||
|
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
||||||
|
// 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序
|
||||||
|
return success ? { success: true, type: 'manual_reorder' } : { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
// 构建新的文件路径
|
// 构建新的文件路径
|
||||||
const sourceName = sourceNode.externalPath!.split('/').pop()!
|
const sourceName = sourceNode.externalPath!.split('/').pop()!
|
||||||
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
|
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
|
||||||
@@ -250,14 +266,15 @@ export async function moveNode(
|
|||||||
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
|
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
|
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
||||||
|
return success ? { success: true, type: 'file_system_move' } : { success: false }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Move nodes failed:', error as Error)
|
logger.error('Move nodes failed:', error as Error)
|
||||||
return false
|
return { success: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ export async function moveNodeInTree(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先保存源节点的副本,以防操作失败需要恢复(暂未实现恢复逻辑)
|
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
|
||||||
// const sourceNodeCopy = { ...sourceNode }
|
const sourceParent = findParentNode(tree, sourceNodeId)
|
||||||
|
const targetParent = findParentNode(tree, targetNodeId)
|
||||||
|
|
||||||
// 从原位置移除节点(不保存数据库,只在内存中操作)
|
// 从原位置移除节点(不保存数据库,只在内存中操作)
|
||||||
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
|
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
|
||||||
@@ -110,7 +111,6 @@ export async function moveNodeInTree(
|
|||||||
|
|
||||||
sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}`
|
sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}`
|
||||||
} else {
|
} else {
|
||||||
const targetParent = findParentNode(tree, targetNodeId)
|
|
||||||
const targetList = targetParent ? targetParent.children! : tree
|
const targetList = targetParent ? targetParent.children! : tree
|
||||||
const targetIndex = targetList.findIndex((node) => node.id === targetNodeId)
|
const targetIndex = targetList.findIndex((node) => node.id === targetNodeId)
|
||||||
|
|
||||||
@@ -123,11 +123,16 @@ export async function moveNodeInTree(
|
|||||||
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
|
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
|
||||||
targetList.splice(insertIndex, 0, sourceNode)
|
targetList.splice(insertIndex, 0, sourceNode)
|
||||||
|
|
||||||
// 更新节点路径
|
// 检查是否为同级排序,如果是则保持原有的 treePath
|
||||||
if (targetParent) {
|
const isSameLevelReorder = sourceParent === targetParent
|
||||||
sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}`
|
|
||||||
} else {
|
// 只有在跨级移动时才更新节点路径
|
||||||
sourceNode.treePath = `/${sourceNode.name}`
|
if (!isSameLevelReorder) {
|
||||||
|
if (targetParent) {
|
||||||
|
sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}`
|
||||||
|
} else {
|
||||||
|
sourceNode.treePath = `/${sourceNode.name}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { removeTab, setActiveTab } from '@renderer/store/tabs'
|
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'
|
import NavigationService from './NavigationService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('TabsService')
|
const logger = loggerService.withContext('TabsService')
|
||||||
|
|
||||||
class 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
|
* @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 移除标签页
|
// 使用 Redux action 移除标签页
|
||||||
store.dispatch(removeTab(tabId))
|
store.dispatch(removeTab(tabId))
|
||||||
|
|
||||||
@@ -56,6 +76,32 @@ class TabsService {
|
|||||||
return true
|
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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有标签页
|
* 获取所有标签页
|
||||||
*/
|
*/
|
||||||
|
|||||||
120
src/renderer/src/utils/webviewStateManager.ts
Normal file
120
src/renderer/src/utils/webviewStateManager.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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>()
|
||||||
|
|
||||||
|
// Per-app listeners (fine grained)
|
||||||
|
type WebviewStateListener = (loaded: boolean) => void
|
||||||
|
const appListeners = new Map<string, Set<WebviewStateListener>>()
|
||||||
|
|
||||||
|
const emitState = (appId: string, loaded: boolean) => {
|
||||||
|
const listeners = appListeners.get(appId)
|
||||||
|
if (listeners && listeners.size) {
|
||||||
|
listeners.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(loaded)
|
||||||
|
} catch (e) {
|
||||||
|
// Swallow listener errors to avoid breaking others
|
||||||
|
logger.debug(`Listener error for ${appId}: ${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}`)
|
||||||
|
emitState(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}`)
|
||||||
|
}
|
||||||
|
// 清掉监听(避免潜在内存泄漏)
|
||||||
|
appListeners.delete(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all WebView states
|
||||||
|
*/
|
||||||
|
export const clearAllWebviewStates = () => {
|
||||||
|
const count = globalWebviewStates.size
|
||||||
|
globalWebviewStates.clear()
|
||||||
|
logger.debug(`Cleared all WebView states (${count} apps)`)
|
||||||
|
appListeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a specific app's webview loaded state changes.
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export const onWebviewStateChange = (appId: string, listener: WebviewStateListener): (() => void) => {
|
||||||
|
let listeners = appListeners.get(appId)
|
||||||
|
if (!listeners) {
|
||||||
|
listeners = new Set<WebviewStateListener>()
|
||||||
|
appListeners.set(appId, listeners)
|
||||||
|
}
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners!.delete(listener)
|
||||||
|
if (listeners!.size === 0) appListeners.delete(appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise helper: wait until the webview becomes loaded.
|
||||||
|
* Optional timeout (ms) to avoid hanging forever; resolves false on timeout.
|
||||||
|
*/
|
||||||
|
export const waitForWebviewLoaded = (appId: string, timeout = 15000): Promise<boolean> => {
|
||||||
|
if (getWebviewLoaded(appId)) return Promise.resolve(true)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false
|
||||||
|
const unsubscribe = onWebviewStateChange(appId, (loaded) => {
|
||||||
|
if (!loaded) return
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
unsubscribe()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
if (timeout > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
unsubscribe()
|
||||||
|
resolve(false)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user