mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
* launch/tray feature enhance stashed * feature: Issue #2754. launch on boot(win&mac, linux not supported now), min to tray when launch(not only boot), min to tray when close bug-fix: Issue #2576. In Mac, if tray-on-close is set, MainWindow will not show on the dock when closed bug-fix: MiniWindow will hide MainWindow when it shows first time and won't hide MainWindow later. The user will not open the MainWindow again if the tray is set to not show. The bug fixed by not hiding the MainWindow anytime the MiniWindow showed. * migration version fix * fix: enable universal shortcuts when launch to tray * ✨ feat: add Model Context Protocol (MCP) support (#2809) * ✨ feat: add Model Context Protocol (MCP) server configuration (main) - Added `@modelcontextprotocol/sdk` dependency for MCP integration. - Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities. - Created `useMCPServers` hook to manage MCP server state and actions. - Added i18n support for MCP settings with translation keys. - Integrated MCP settings into the application's settings navigation and routing. - Implemented Redux state management for MCP servers. - Updated `yarn.lock` with new dependencies and their resolutions. * 🌟 feat: implement mcp service and integrate with ipc handlers - Added `MCPService` class to manage Model Context Protocol servers. - Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers. - Integrated MCP related types into existing type declarations for consistency across the application. - Updated `preload` to expose new MCP related APIs to the renderer process. - Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states. - Introduced selectors in the MCP Redux slice for fetching active and all servers from the store. - Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application. * feat: enhance MCPService initialization to prevent recursive calls and improve error handling * feat: enhance MCP integration by adding MCPTool type and updating related methods * feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing * fix: finish_reason undefined --------- Co-authored-by: LiuVaayne <10231735+vaayne@users.noreply.github.com> Co-authored-by: kangfenmao <kangfenmao@qq.com>
476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
import { is } from '@electron-toolkit/utils'
|
||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||
import { getFilesDir } from '@main/utils/file'
|
||
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
|
||
import Logger from 'electron-log'
|
||
import windowStateKeeper from 'electron-window-state'
|
||
import { join } from 'path'
|
||
|
||
import icon from '../../../build/icon.png?asset'
|
||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||
import { locales } from '../utils/locales'
|
||
import { configManager } from './ConfigManager'
|
||
|
||
export class WindowService {
|
||
private static instance: WindowService | null = null
|
||
private mainWindow: BrowserWindow | null = null
|
||
private miniWindow: BrowserWindow | null = null
|
||
private wasFullScreen: boolean = false
|
||
private selectionMenuWindow: BrowserWindow | null = null
|
||
private lastSelectedText: string = ''
|
||
private contextMenu: Menu | null = null
|
||
|
||
public static getInstance(): WindowService {
|
||
if (!WindowService.instance) {
|
||
WindowService.instance = new WindowService()
|
||
}
|
||
return WindowService.instance
|
||
}
|
||
|
||
public createMainWindow(): BrowserWindow {
|
||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||
this.mainWindow.show()
|
||
return this.mainWindow
|
||
}
|
||
|
||
const mainWindowState = windowStateKeeper({
|
||
defaultWidth: 1080,
|
||
defaultHeight: 670
|
||
})
|
||
|
||
const theme = configManager.getTheme()
|
||
|
||
this.mainWindow = new BrowserWindow({
|
||
x: mainWindowState.x,
|
||
y: mainWindowState.y,
|
||
width: mainWindowState.width,
|
||
height: mainWindowState.height,
|
||
minWidth: 1080,
|
||
minHeight: 600,
|
||
show: false, // 初始不显示
|
||
autoHideMenuBar: true,
|
||
transparent: isMac,
|
||
vibrancy: 'sidebar',
|
||
visualEffectState: 'active',
|
||
titleBarStyle: isLinux ? 'default' : 'hidden',
|
||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||
trafficLightPosition: { x: 8, y: 12 },
|
||
...(process.platform === 'linux' ? { icon } : {}),
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/index.js'),
|
||
sandbox: false,
|
||
webSecurity: false,
|
||
webviewTag: true,
|
||
allowRunningInsecureContent: true
|
||
}
|
||
})
|
||
|
||
this.setupMainWindow(this.mainWindow, mainWindowState)
|
||
|
||
return this.mainWindow
|
||
}
|
||
|
||
public createMinappWindow({
|
||
url,
|
||
parent,
|
||
windowOptions
|
||
}: {
|
||
url: string
|
||
parent?: BrowserWindow
|
||
windowOptions?: Electron.BrowserWindowConstructorOptions
|
||
}): BrowserWindow {
|
||
const width = windowOptions?.width || 1000
|
||
const height = windowOptions?.height || 680
|
||
|
||
const minappWindow = new BrowserWindow({
|
||
width,
|
||
height,
|
||
autoHideMenuBar: true,
|
||
title: 'Cherry Studio',
|
||
...windowOptions,
|
||
parent,
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/minapp.js'),
|
||
sandbox: false,
|
||
contextIsolation: false
|
||
}
|
||
})
|
||
|
||
minappWindow.loadURL(url)
|
||
return minappWindow
|
||
}
|
||
|
||
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
|
||
mainWindowState.manage(mainWindow)
|
||
|
||
this.setupContextMenu(mainWindow)
|
||
this.setupWindowEvents(mainWindow)
|
||
this.setupWebContentsHandlers(mainWindow)
|
||
this.setupWindowLifecycleEvents(mainWindow)
|
||
this.loadMainWindowContent(mainWindow)
|
||
}
|
||
|
||
private setupContextMenu(mainWindow: BrowserWindow) {
|
||
if (!this.contextMenu) {
|
||
const locale = locales[configManager.getLanguage()]
|
||
const { common } = locale.translation
|
||
|
||
this.contextMenu = new Menu()
|
||
this.contextMenu.append(new MenuItem({ label: common.copy, role: 'copy' }))
|
||
this.contextMenu.append(new MenuItem({ label: common.paste, role: 'paste' }))
|
||
this.contextMenu.append(new MenuItem({ label: common.cut, role: 'cut' }))
|
||
}
|
||
|
||
mainWindow.webContents.on('context-menu', () => {
|
||
this.contextMenu?.popup()
|
||
})
|
||
|
||
// Dangerous API
|
||
if (isDev) {
|
||
mainWindow.webContents.on('will-attach-webview', (_, webPreferences) => {
|
||
webPreferences.preload = join(__dirname, '../preload/index.js')
|
||
})
|
||
}
|
||
|
||
// Handle webview context menu
|
||
mainWindow.webContents.on('did-attach-webview', (_, webContents) => {
|
||
webContents.on('context-menu', () => {
|
||
this.contextMenu?.popup()
|
||
})
|
||
})
|
||
}
|
||
|
||
private setupWindowEvents(mainWindow: BrowserWindow) {
|
||
mainWindow.once('ready-to-show', () => {
|
||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||
|
||
// show window only when laucn to tray not set
|
||
const isLaunchToTray = configManager.getLaunchToTray()
|
||
if (!isLaunchToTray) {
|
||
mainWindow.show()
|
||
}
|
||
})
|
||
|
||
// 处理全屏相关事件
|
||
mainWindow.on('enter-full-screen', () => {
|
||
this.wasFullScreen = true
|
||
mainWindow.webContents.send('fullscreen-status-changed', true)
|
||
})
|
||
|
||
mainWindow.on('leave-full-screen', () => {
|
||
this.wasFullScreen = false
|
||
mainWindow.webContents.send('fullscreen-status-changed', false)
|
||
})
|
||
|
||
// 添加Escape键退出全屏的支持
|
||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
||
if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
|
||
if (mainWindow.isFullScreen()) {
|
||
event.preventDefault()
|
||
mainWindow.setFullScreen(false)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||
if (url.includes('localhost:5173')) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
shell.openExternal(url)
|
||
})
|
||
|
||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||
const { url } = details
|
||
|
||
const oauthProviderUrls = [
|
||
'https://account.siliconflow.cn/oauth',
|
||
'https://cloud.siliconflow.cn/expensebill',
|
||
'https://aihubmix.com/token',
|
||
'https://aihubmix.com/topup'
|
||
]
|
||
|
||
if (oauthProviderUrls.some((link) => url.startsWith(link))) {
|
||
return {
|
||
action: 'allow',
|
||
overrideBrowserWindowOptions: {
|
||
webPreferences: {
|
||
partition: 'persist:webview'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (url.includes('http://file/')) {
|
||
const fileName = url.replace('http://file/', '')
|
||
const storageDir = getFilesDir()
|
||
const filePath = storageDir + '/' + fileName
|
||
shell.openPath(filePath).catch((err) => Logger.error('Failed to open file:', err))
|
||
} else {
|
||
shell.openExternal(details.url)
|
||
}
|
||
|
||
return { action: 'deny' }
|
||
})
|
||
|
||
this.setupWebRequestHeaders(mainWindow)
|
||
}
|
||
|
||
private setupWebRequestHeaders(mainWindow: BrowserWindow) {
|
||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||
if (details.responseHeaders?.['X-Frame-Options']) {
|
||
delete details.responseHeaders['X-Frame-Options']
|
||
}
|
||
if (details.responseHeaders?.['x-frame-options']) {
|
||
delete details.responseHeaders['x-frame-options']
|
||
}
|
||
if (details.responseHeaders?.['Content-Security-Policy']) {
|
||
delete details.responseHeaders['Content-Security-Policy']
|
||
}
|
||
if (details.responseHeaders?.['content-security-policy']) {
|
||
delete details.responseHeaders['content-security-policy']
|
||
}
|
||
callback({ cancel: false, responseHeaders: details.responseHeaders })
|
||
})
|
||
}
|
||
|
||
private loadMainWindowContent(mainWindow: BrowserWindow) {
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||
} else {
|
||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||
}
|
||
}
|
||
|
||
public getMainWindow(): BrowserWindow | null {
|
||
return this.mainWindow
|
||
}
|
||
|
||
private setupWindowLifecycleEvents(mainWindow: BrowserWindow) {
|
||
mainWindow.on('close', (event) => {
|
||
// 如果已经触发退出,直接退出
|
||
if (app.isQuitting) {
|
||
return app.quit()
|
||
}
|
||
|
||
// 托盘及关闭行为设置
|
||
const isShowTray = configManager.getTray()
|
||
const isTrayOnClose = configManager.getTrayOnClose()
|
||
// 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出
|
||
if (!isShowTray || (isShowTray && !isTrayOnClose)) {
|
||
// 如果是Windows或Linux,直接退出
|
||
// mac按照系统默认行为,不退出
|
||
if (isWin || isLinux) {
|
||
return app.quit()
|
||
}
|
||
}
|
||
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
|
||
|
||
// 如果是Windows或Linux,且处于全屏状态,则退出应用
|
||
if (this.wasFullScreen) {
|
||
if (isWin || isLinux) {
|
||
return app.quit()
|
||
} else {
|
||
event.preventDefault()
|
||
mainWindow.setFullScreen(false)
|
||
return
|
||
}
|
||
}
|
||
event.preventDefault()
|
||
mainWindow.hide()
|
||
app.dock?.hide() //for mac to hide to tray
|
||
})
|
||
|
||
mainWindow.on('closed', () => {
|
||
this.mainWindow = null
|
||
})
|
||
|
||
mainWindow.on('show', () => {
|
||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||
this.miniWindow.hide()
|
||
}
|
||
})
|
||
}
|
||
|
||
public showMainWindow() {
|
||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||
this.miniWindow.hide()
|
||
}
|
||
|
||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||
if (this.mainWindow.isMinimized()) {
|
||
return this.mainWindow.restore()
|
||
}
|
||
this.mainWindow.show()
|
||
this.mainWindow.focus()
|
||
} else {
|
||
this.mainWindow = this.createMainWindow()
|
||
this.mainWindow.focus()
|
||
}
|
||
//for mac users, when window is shown, should show dock icon (dock may be set to hide when launch)
|
||
app.dock?.show()
|
||
}
|
||
|
||
public showMiniWindow() {
|
||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||
|
||
if (!enableQuickAssistant) {
|
||
return
|
||
}
|
||
|
||
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
|
||
this.selectionMenuWindow.hide()
|
||
}
|
||
|
||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||
if (this.miniWindow.isMinimized()) {
|
||
this.miniWindow.restore()
|
||
}
|
||
this.miniWindow.show()
|
||
this.miniWindow.center()
|
||
this.miniWindow.focus()
|
||
return
|
||
}
|
||
|
||
this.miniWindow = new BrowserWindow({
|
||
width: 500,
|
||
height: 520,
|
||
show: true,
|
||
autoHideMenuBar: true,
|
||
transparent: isMac,
|
||
vibrancy: 'under-window',
|
||
visualEffectState: 'followWindow',
|
||
center: true,
|
||
frame: false,
|
||
alwaysOnTop: true,
|
||
resizable: false,
|
||
useContentSize: true,
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/index.js'),
|
||
sandbox: false,
|
||
webSecurity: false,
|
||
webviewTag: true
|
||
}
|
||
})
|
||
|
||
this.miniWindow.on('blur', () => {
|
||
this.miniWindow?.hide()
|
||
})
|
||
|
||
this.miniWindow.on('closed', () => {
|
||
this.miniWindow = null
|
||
})
|
||
|
||
this.miniWindow.on('hide', () => {
|
||
this.miniWindow?.webContents.send('hide-mini-window')
|
||
})
|
||
|
||
this.miniWindow.on('show', () => {
|
||
this.miniWindow?.webContents.send('show-mini-window')
|
||
})
|
||
|
||
ipcMain.on('miniwindow-reload', () => {
|
||
this.miniWindow?.reload()
|
||
})
|
||
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/mini')
|
||
} else {
|
||
this.miniWindow.loadFile(join(__dirname, '../renderer/index.html'), {
|
||
hash: '#/mini'
|
||
})
|
||
}
|
||
}
|
||
|
||
public hideMiniWindow() {
|
||
this.miniWindow?.hide()
|
||
}
|
||
|
||
public closeMiniWindow() {
|
||
this.miniWindow?.close()
|
||
}
|
||
|
||
public toggleMiniWindow() {
|
||
if (this.miniWindow) {
|
||
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
|
||
} else {
|
||
this.showMiniWindow()
|
||
}
|
||
}
|
||
|
||
public showSelectionMenu(bounds: { x: number; y: number }) {
|
||
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
|
||
this.selectionMenuWindow.setPosition(bounds.x, bounds.y)
|
||
this.selectionMenuWindow.show()
|
||
return
|
||
}
|
||
|
||
const theme = configManager.getTheme()
|
||
|
||
this.selectionMenuWindow = new BrowserWindow({
|
||
width: 280,
|
||
height: 40,
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
show: true,
|
||
autoHideMenuBar: true,
|
||
transparent: true,
|
||
frame: false,
|
||
alwaysOnTop: false,
|
||
skipTaskbar: true,
|
||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||
resizable: false,
|
||
vibrancy: 'popover',
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/index.js'),
|
||
sandbox: false,
|
||
webSecurity: false
|
||
}
|
||
})
|
||
|
||
// 点击其他地方时隐藏窗口
|
||
this.selectionMenuWindow.on('blur', () => {
|
||
this.selectionMenuWindow?.hide()
|
||
this.miniWindow?.webContents.send('selection-action', {
|
||
action: 'home',
|
||
selectedText: this.lastSelectedText
|
||
})
|
||
})
|
||
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
this.selectionMenuWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/menu/menu.html')
|
||
} else {
|
||
this.selectionMenuWindow.loadFile(join(__dirname, '../renderer/src/windows/menu/menu.html'))
|
||
}
|
||
|
||
this.setupSelectionMenuEvents()
|
||
}
|
||
|
||
private setupSelectionMenuEvents() {
|
||
if (!this.selectionMenuWindow) return
|
||
|
||
ipcMain.removeHandler('selection-menu:action')
|
||
ipcMain.handle('selection-menu:action', (_, action) => {
|
||
this.selectionMenuWindow?.hide()
|
||
this.showMiniWindow()
|
||
setTimeout(() => {
|
||
this.miniWindow?.webContents.send('selection-action', {
|
||
action,
|
||
selectedText: this.lastSelectedText
|
||
})
|
||
}, 100)
|
||
})
|
||
}
|
||
|
||
public setLastSelectedText(text: string) {
|
||
this.lastSelectedText = text
|
||
}
|
||
}
|
||
|
||
export const windowService = WindowService.getInstance()
|