From 25c166cb8ecdf067113a7aace178e36fdad82f0e Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Fri, 21 Mar 2025 16:45:42 +0800 Subject: [PATCH] feat: Launch on boot, Minimize to tray on launch & on close / fix: Mac: don't show dock when close to tray (#2871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/main/index.ts | 7 ++ src/main/ipc.ts | 28 ++++++++ src/main/services/ConfigManager.ts | 16 +++++ src/main/services/ShortcutService.ts | 20 +++++- src/main/services/WindowService.ts | 35 ++++++---- src/preload/index.d.ts | 3 + src/preload/index.ts | 3 + src/renderer/src/hooks/useSettings.ts | 31 +++++++-- src/renderer/src/i18n/locales/en-us.json | 7 +- src/renderer/src/i18n/locales/ja-jp.json | 7 +- src/renderer/src/i18n/locales/ru-ru.json | 7 +- src/renderer/src/i18n/locales/zh-cn.json | 7 +- src/renderer/src/i18n/locales/zh-tw.json | 7 +- .../src/pages/settings/GeneralSettings.tsx | 67 +++++++++++++++++-- src/renderer/src/store/migrate.ts | 3 + src/renderer/src/store/settings.ts | 18 +++++ 16 files changed, 237 insertions(+), 29 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 907ef2b6..970dbaf5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { app, ipcMain } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' +import { configManager } from './services/ConfigManager' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -21,6 +22,12 @@ if (!app.requestSingleInstanceLock()) { // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') + // Mac: Hide dock icon before window creation when launch to tray is set + const isLaunchToTray = configManager.getLaunchToTray() + if (isLaunchToTray) { + app.dock?.hide() + } + const mainWindow = windowService.createMainWindow() new TrayService() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 10255e56..db2eb0ea 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' +import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { MCPServer, Shortcut, ThemeMode } from '@types' import { BrowserWindow, ipcMain, session, shell } from 'electron' @@ -68,11 +69,38 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setLanguage(language) }) + // launch on boot + ipcMain.handle('app:set-launch-on-boot', (_, isActive: boolean) => { + // Set login item settings for windows and mac + // linux is not supported because it requires more file operations + if (isWin || isMac) { + if (isActive) { + app.setLoginItemSettings({ + openAtLogin: true + }) + } else { + app.setLoginItemSettings({ + openAtLogin: false + }) + } + } + }) + + // launch to tray + ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => { + configManager.setLaunchToTray(isActive) + }) + // tray ipcMain.handle('app:set-tray', (_, isActive: boolean) => { configManager.setTray(isActive) }) + // to tray on close + ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => { + configManager.setTrayOnClose(isActive) + }) + ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray()) ipcMain.handle('config:set', (_, key: string, value: any) => { diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 719d089e..da20bc41 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -30,6 +30,14 @@ export class ConfigManager { this.store.set('theme', theme) } + getLaunchToTray(): boolean { + return !!this.store.get('launchToTray', false) + } + + setLaunchToTray(value: boolean) { + this.store.set('launchToTray', value) + } + getTray(): boolean { return !!this.store.get('tray', true) } @@ -39,6 +47,14 @@ export class ConfigManager { this.notifySubscribers('tray', value) } + getTrayOnClose(): boolean { + return !!this.store.get('trayOnClose', true) + } + + setTrayOnClose(value: boolean) { + this.store.set('trayOnClose', value) + } + getZoomFactor(): number { return this.store.get('zoomFactor', 1) as number } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 0738a6b9..10d46730 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -115,7 +115,20 @@ const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutForm } export function registerShortcuts(window: BrowserWindow) { - const register = () => { + window.once('ready-to-show', () => { + if (configManager.getLaunchToTray()) { + registerOnlyUniversalShortcuts() + } + }) + + //only for clearer code + const registerOnlyUniversalShortcuts = () => { + register(true) + } + + //onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window + //onlyUniversalShortcuts is needed when we launch to tray + const register = (onlyUniversalShortcuts: boolean = false) => { if (window.isDestroyed()) return const shortcuts = configManager.getShortcuts() @@ -131,6 +144,11 @@ export function registerShortcuts(window: BrowserWindow) { if (!shortcut.enabled) { return } + + // only register universal shortcuts when needed + if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) { + return + } const handler = getShortcutHandler(shortcut) if (!handler) { diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 14bdef4e..9ae1c1dc 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -1,5 +1,5 @@ import { is } from '@electron-toolkit/utils' -import { isDev, isLinux, isWin } from '@main/constant' +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' @@ -39,8 +39,6 @@ export class WindowService { }) const theme = configManager.getTheme() - const isMac = process.platform === 'darwin' - const isLinux = process.platform === 'linux' this.mainWindow = new BrowserWindow({ x: mainWindowState.x, @@ -146,7 +144,12 @@ export class WindowService { private setupWindowEvents(mainWindow: BrowserWindow) { mainWindow.once('ready-to-show', () => { mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) - mainWindow.show() + + // show window only when laucn to tray not set + const isLaunchToTray = configManager.getLaunchToTray() + if (!isLaunchToTray) { + mainWindow.show() + } }) // 处理全屏相关事件 @@ -255,11 +258,18 @@ export class WindowService { return app.quit() } - // 没有开启托盘,且是Windows或Linux系统,直接退出 - const notInTray = !configManager.getTray() - if ((isWin || isLinux) && notInTray) { - 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) { @@ -273,6 +283,7 @@ export class WindowService { } event.preventDefault() mainWindow.hide() + app.dock?.hide() //for mac to hide to tray }) mainWindow.on('closed', () => { @@ -301,6 +312,8 @@ export class WindowService { 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() { @@ -310,9 +323,6 @@ export class WindowService { return } - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.hide() - } if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { this.selectionMenuWindow.hide() } @@ -327,8 +337,6 @@ export class WindowService { return } - const isMac = process.platform === 'darwin' - this.miniWindow = new BrowserWindow({ width: 500, height: 520, @@ -403,7 +411,6 @@ export class WindowService { } const theme = configManager.getTheme() - const isMac = process.platform === 'darwin' this.selectionMenuWindow = new BrowserWindow({ width: 280, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4d0b8804..7384816b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -23,7 +23,10 @@ declare global { openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void setLanguage: (theme: LanguageVarious) => void + setLaunchOnBoot: (isActive: boolean) => void + setLaunchToTray: (isActive: boolean) => void setTray: (isActive: boolean) => void + setTrayOnClose: (isActive: boolean) => void restartTray: () => void setTheme: (theme: 'light' | 'dark') => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 831b59a7..0a1ea8c5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,7 +11,10 @@ const api = { checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'), showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'), setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang), + setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-on-boot', isActive), + setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-to-tray', isActive), setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive), + setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke('app:set-tray-on-close', isActive), restartTray: () => ipcRenderer.invoke('app:restart-tray'), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index abe673c2..0a05a162 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -1,6 +1,8 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { SendMessageShortcut, + setLaunchOnBoot, + setLaunchToTray, setSendMessageShortcut as _setSendMessageShortcut, setShowAssistantIcon, setSidebarIcons, @@ -8,7 +10,8 @@ import { setTheme, SettingsState, setTopicPosition, - setTray, + setTray as _setTray, + setTrayOnClose, setWindowStyle } from '@renderer/store/settings' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -22,10 +25,30 @@ export function useSettings() { setSendMessageShortcut(shortcut: SendMessageShortcut) { dispatch(_setSendMessageShortcut(shortcut)) }, - setTray(isActive: boolean) { - dispatch(setTray(isActive)) - window.api.setTray(isActive) + + setLaunch(isLaunchOnBoot: boolean | undefined, isLaunchToTray: boolean | undefined = undefined) { + if (isLaunchOnBoot !== undefined) { + dispatch(setLaunchOnBoot(isLaunchOnBoot)) + window.api.setLaunchOnBoot(isLaunchOnBoot) + } + + if (isLaunchToTray !== undefined) { + dispatch(setLaunchToTray(isLaunchToTray)) + window.api.setLaunchToTray(isLaunchToTray) + } }, + + setTray(isShowTray: boolean | undefined, isTrayOnClose: boolean | undefined = undefined) { + if (isShowTray !== undefined) { + dispatch(_setTray(isShowTray)) + window.api.setTray(isShowTray) + } + if (isTrayOnClose !== undefined) { + dispatch(setTrayOnClose(isTrayOnClose)) + window.api.setTrayOnClose(isTrayOnClose) + } + }, + setTheme(theme: ThemeMode) { dispatch(setTheme(theme)) }, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8c68ccdb..5223dc5f 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1095,7 +1095,12 @@ "topic.position.left": "Left", "topic.position.right": "Right", "topic.show.time": "Show topic time", - "tray.title": "Enable System Tray Icon", + "tray.title": "Tray", + "tray.show": "Show Tray Icon", + "tray.onclose": "Minimize to Tray on Close", + "launch.title": "Launch", + "launch.onboot": "Start Automatically on Boot", + "launch.totray": "Minimize to Tray on Launch", "websearch": { "blacklist": "Blacklist", "blacklist_description": "Results from the following websites will not appear in search results", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 68066152..165d242d 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1095,7 +1095,12 @@ "topic.position.left": "左", "topic.position.right": "右", "topic.show.time": "トピックの時間を表示", - "tray.title": "システムトレイアイコンを有効にする", + "tray.title": "トレイ", + "tray.show": "トレイアイコンを表示", + "tray.onclose": "閉じるときにトレイに最小化", + "launch.title": "起動", + "launch.onboot": "起動時に自動で開始", + "launch.totray": "起動時にトレイに最小化", "websearch": { "blacklist": "ブラックリスト", "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index b1b32680..46286ff5 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1095,7 +1095,12 @@ "topic.position.left": "Слева", "topic.position.right": "Справа", "topic.show.time": "Показывать время топика", - "tray.title": "Включить значок системного трея", + "tray.title": "Трей", + "tray.show": "Показать значок в трее", + "tray.onclose": "Свернуть в трей при закрытии", + "launch.title": "Запуск", + "launch.onboot": "Автозапуск при включении", + "launch.totray": "Свернуть в трей при запуске", "websearch": { "blacklist": "Черный список", "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b08379f0..af39e01e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1095,7 +1095,12 @@ "topic.position.left": "左侧", "topic.position.right": "右侧", "topic.show.time": "显示话题时间", - "tray.title": "启用系统托盘图标", + "tray.title": "托盘", + "tray.show": "显示托盘图标", + "tray.onclose": "关闭时最小化到托盘", + "launch.title": "启动", + "launch.onboot": "开机自动启动", + "launch.totray": "启动时最小化到托盘", "websearch": { "blacklist": "黑名单", "blacklist_description": "在搜索结果中不会出现以下网站的结果", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 12d92d30..937b1d52 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1095,7 +1095,12 @@ "topic.position.left": "左側", "topic.position.right": "右側", "topic.show.time": "顯示話題時間", - "tray.title": "啟用系統工具列圖示", + "tray.title": "系统匣", + "tray.show": "顯示系统匣圖示", + "tray.onclose": "關閉時最小化到系统匣", + "launch.title": "啟動", + "launch.onboot": "開機自動啟動", + "launch.totray": "啟動時最小化到系统匣", "websearch": { "blacklist": "黑名單", "blacklist_description": "以下網站不會出現在搜尋結果中", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index e73e3382..eed439d8 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -13,13 +13,47 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.' const GeneralSettings: FC = () => { - const { language, proxyUrl: storeProxyUrl, theme, setTray, tray, proxyMode: storeProxyMode } = useSettings() + const { + language, + proxyUrl: storeProxyUrl, + theme, + setLaunch, + setTray, + launchOnBoot, + launchToTray, + trayOnClose, + tray, + proxyMode: storeProxyMode + } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) const { theme: themeMode } = useTheme() - const updateTray = (value: boolean) => { - setTray(value) - window.api.setTray(value) + const updateTray = (isShowTray: boolean) => { + setTray(isShowTray) + //only set tray on close/launch to tray when tray is enabled + if (!isShowTray) { + updateTrayOnClose(false) + updateLaunchToTray(false) + } + } + + const updateTrayOnClose = (isTrayOnClose: boolean) => { + setTray(undefined, isTrayOnClose) + //in case tray is not enabled, enable it + if (isTrayOnClose && !tray) { + updateTray(true) + } + } + + const updateLaunchOnBoot = (isLaunchOnBoot: boolean) => { + setLaunch(isLaunchOnBoot) + } + + const updateLaunchToTray = (isLaunchToTray: boolean) => { + setLaunch(undefined, isLaunchToTray) + if (isLaunchToTray && !tray) { + updateTray(true) + } } const dispatch = useAppDispatch() @@ -52,8 +86,10 @@ const GeneralSettings: FC = () => { dispatch(setProxyMode(mode)) if (mode === 'system') { window.api.setProxy('system') + dispatch(_setProxyUrl(undefined)) } else if (mode === 'none') { window.api.setProxy(undefined) + dispatch(_setProxyUrl(undefined)) } } @@ -111,11 +147,32 @@ const GeneralSettings: FC = () => { )} + + + {t('settings.launch.title')} - {t('settings.tray.title')} + {t('settings.launch.onboot')} + updateLaunchOnBoot(checked)} /> + + + + {t('settings.launch.totray')} + updateLaunchToTray(checked)} /> + + + + {t('settings.tray.title')} + + + {t('settings.tray.show')} updateTray(checked)} /> + + + {t('settings.tray.onclose')} + updateTrayOnClose(checked)} disabled={!tray} /> + ) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f0330d58..a781101a 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -791,6 +791,9 @@ const migrateConfig = { }, '83': (state: RootState) => { state.settings.messageNavigation = 'buttons' + state.settings.launchOnBoot = false + state.settings.launchToTray = false + state.settings.trayOnClose = true return state } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index b532c8e6..85999c54 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -28,6 +28,9 @@ export interface SettingsState { showMessageDivider: boolean messageFont: 'system' | 'serif' showInputEstimatedTokens: boolean + launchOnBoot: boolean + launchToTray: boolean + trayOnClose: boolean tray: boolean theme: ThemeMode windowStyle: 'transparent' | 'opaque' @@ -103,6 +106,9 @@ const initialState: SettingsState = { showMessageDivider: true, messageFont: 'system', showInputEstimatedTokens: false, + launchOnBoot: false, + launchToTray: false, + trayOnClose: true, tray: true, theme: ThemeMode.auto, windowStyle: 'transparent', @@ -205,9 +211,18 @@ const settingsSlice = createSlice({ setShowInputEstimatedTokens: (state, action: PayloadAction) => { state.showInputEstimatedTokens = action.payload }, + setLaunchOnBoot: (state, action: PayloadAction) => { + state.launchOnBoot = action.payload + }, + setLaunchToTray: (state, action: PayloadAction) => { + state.launchToTray = action.payload + }, setTray: (state, action: PayloadAction) => { state.tray = action.payload }, + setTrayOnClose: (state, action: PayloadAction) => { + state.trayOnClose = action.payload + }, setTheme: (state, action: PayloadAction) => { state.theme = action.payload }, @@ -386,6 +401,9 @@ export const { setShowMessageDivider, setMessageFont, setShowInputEstimatedTokens, + setLaunchOnBoot, + setLaunchToTray, + setTrayOnClose, setTray, setTheme, setFontSize,