import { is } from '@electron-toolkit/utils' import { isDev, isLinux, 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() const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' 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()) 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() } // 没有开启托盘,且是Windows或Linux系统,直接退出 const notInTray = !configManager.getTray() if ((isWin || isLinux) && notInTray) { 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() }) 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()) { this.mainWindow.restore() } this.mainWindow.show() this.mainWindow.focus() } else { this.mainWindow = this.createMainWindow() this.mainWindow.focus() } } public showMiniWindow() { const enableQuickAssistant = configManager.getEnableQuickAssistant() if (!enableQuickAssistant) { return } if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.hide() } 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 } const isMac = process.platform === 'darwin' 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() const isMac = process.platform === 'darwin' 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()