diff --git a/build/tray_icon_dark.png b/build/tray_icon_dark.png new file mode 100644 index 00000000..0ee6086a Binary files /dev/null and b/build/tray_icon_dark.png differ diff --git a/build/tray_icon_light.png b/build/tray_icon_light.png new file mode 100644 index 00000000..71181c0d Binary files /dev/null and b/build/tray_icon_light.png differ diff --git a/src/main/electron.d.ts b/src/main/electron.d.ts new file mode 100644 index 00000000..877df783 --- /dev/null +++ b/src/main/electron.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace Electron { + interface App { + isQuitting: boolean + } + } +} + +export {} diff --git a/src/main/index.ts b/src/main/index.ts index 35de52f8..668a9f9a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,8 +4,9 @@ import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' import { registerZoomShortcut } from './services/ShortcutService' +import { TrayService } from './services/TrayService' +import { windowService } from './services/WindowService' import { updateUserDataPath } from './utils/upgrade' -import { createMainWindow } from './window' // Check for single instance lock if (!app.requestSingleInstanceLock()) { @@ -21,21 +22,19 @@ app.whenReady().then(async () => { // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils - app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window) - }) + const mainWindow = windowService.createMainWindow() + new TrayService() app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createMainWindow() + if (BrowserWindow.getAllWindows().length === 0) { + windowService.createMainWindow() + } else { + windowService.showMainWindow() + } }) - const mainWindow = createMainWindow() - registerZoomShortcut(mainWindow) registerIpc(mainWindow, app) @@ -56,13 +55,12 @@ app.on('second-instance', () => { } }) -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } +app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) +}) + +app.on('before-quit', () => { + app.isQuitting = true }) // In this file you can include the rest of your app"s specific main process diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b69ac368..89d3900b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -9,8 +9,8 @@ import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import { ExportService } from './services/ExportService' import FileStorage from './services/FileStorage' +import { windowService } from './services/WindowService' import { compress, decompress } from './utils/zip' -import { createMinappWindow } from './window' const fileManager = new FileStorage() const backupManager = new BackupManager() @@ -79,7 +79,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // minapp ipcMain.handle('minapp', (_, args) => { - createMinappWindow({ + windowService.createMinappWindow({ url: args.url, parent: mainWindow, windowOptions: { diff --git a/src/main/resources/icon.ico b/src/main/resources/icon.ico new file mode 100644 index 00000000..07f1f670 Binary files /dev/null and b/src/main/resources/icon.ico differ diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts new file mode 100644 index 00000000..ab83860f --- /dev/null +++ b/src/main/services/TrayService.ts @@ -0,0 +1,68 @@ +import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron' + +import iconDark from '../../../build/tray_icon_dark.png?asset' +import iconLight from '../../../build/tray_icon_light.png?asset' +import { windowService } from './WindowService' + +export class TrayService { + private tray: Tray | null = null + + constructor() { + this.createTray() + } + + private createTray() { + const iconPath = nativeTheme.shouldUseDarkColors ? iconLight : iconDark + const tray = new Tray(iconPath) + + if (process.platform === 'win32') { + tray.setImage(iconPath) + nativeTheme.on('updated', () => { + const newIconPath = nativeTheme.shouldUseDarkColors ? iconLight : iconDark + tray.setImage(newIconPath) + }) + } else if (process.platform === 'darwin') { + const image = nativeImage.createFromPath(iconPath) + const resizedImage = image.resize({ width: 16, height: 16 }) + resizedImage.setTemplateImage(true) + tray.setImage(resizedImage) + } else if (process.platform === 'linux') { + const image = nativeImage.createFromPath(iconPath) + const resizedImage = image.resize({ width: 24, height: 24 }) + tray.setImage(resizedImage) + nativeTheme.on('updated', () => { + const newIconPath = nativeTheme.shouldUseDarkColors ? iconLight : iconDark + const newImage = nativeImage.createFromPath(newIconPath) + const newResizedImage = newImage.resize({ width: 24, height: 24 }) + tray.setImage(newResizedImage) + }) + } + + this.tray = tray + + const contextMenu = Menu.buildFromTemplate([ + { + label: '显示窗口', + click: () => windowService.showMainWindow() + }, + { + label: '退出', + click: () => this.quit() + } + ]) + + this.tray.setToolTip('Cherry Studio') + + this.tray.on('right-click', () => { + this.tray?.popUpContextMenu(contextMenu) + }) + + this.tray.on('click', () => { + windowService.showMainWindow() + }) + } + + private quit() { + app.quit() + } +} diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts new file mode 100644 index 00000000..63ef5fde --- /dev/null +++ b/src/main/services/WindowService.ts @@ -0,0 +1,194 @@ +import { is } from '@electron-toolkit/utils' +import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron' +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 + + public static getInstance(): WindowService { + if (!WindowService.instance) { + WindowService.instance = new WindowService() + } + return WindowService.instance + } + + public createMainWindow(): BrowserWindow { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + return this.mainWindow + } + + const mainWindowState = windowStateKeeper({ + defaultWidth: 1080, + defaultHeight: 670 + }) + + const theme = configManager.getTheme() + const isMac = process.platform === 'darwin' + + this.mainWindow = new BrowserWindow({ + x: mainWindowState.x, + y: mainWindowState.y, + width: mainWindowState.width, + height: mainWindowState.height, + minWidth: 1080, + minHeight: 600, + show: true, + autoHideMenuBar: true, + transparent: isMac, + vibrancy: 'fullscreen-ui', + visualEffectState: 'active', + titleBarStyle: '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 + } + }) + + 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) { + mainWindow.webContents.on('context-menu', () => { + const locale = locales[configManager.getLanguage()] + const { common } = locale.translation + + const menu = new Menu() + menu.append(new MenuItem({ label: common.copy, role: 'copy' })) + menu.append(new MenuItem({ label: common.paste, role: 'paste' })) + menu.append(new MenuItem({ label: common.cut, role: 'cut' })) + menu.popup() + }) + } + + private setupWindowEvents(mainWindow: BrowserWindow) { + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + } + + private setupWebContentsHandlers(mainWindow: BrowserWindow) { + mainWindow.webContents.on('will-navigate', (event, url) => { + event.preventDefault() + shell.openExternal(url) + }) + + mainWindow.webContents.setWindowOpenHandler((details) => { + 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) { + event.preventDefault() + mainWindow.hide() + } + }) + + mainWindow.on('minimize', (event) => { + event.preventDefault() + mainWindow.hide() + }) + } + + public showMainWindow() { + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore() + } + this.mainWindow.show() + this.mainWindow.focus() + } else { + this.createMainWindow() + } + } +} + +export const windowService = WindowService.getInstance() diff --git a/src/main/window.ts b/src/main/window.ts deleted file mode 100644 index eb678396..00000000 --- a/src/main/window.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { is } from '@electron-toolkit/utils' -import { BrowserWindow, Menu, MenuItem, shell } from 'electron' -import windowStateKeeper from 'electron-window-state' -import { join } from 'path' - -import icon from '../../build/icon.png?asset' -import { titleBarOverlayDark, titleBarOverlayLight } from './config' -import { configManager } from './services/ConfigManager' -import { locales } from './utils/locales' - -export function createMainWindow() { - // Load the previous state with fallback to defaults - const mainWindowState = windowStateKeeper({ - defaultWidth: 1080, - defaultHeight: 670 - }) - - const theme = configManager.getTheme() - - // Create the browser window. - const isMac = process.platform === 'darwin' - - const mainWindow = new BrowserWindow({ - x: mainWindowState.x, - y: mainWindowState.y, - width: mainWindowState.width, - height: mainWindowState.height, - minWidth: 1080, - minHeight: 600, - show: true, - autoHideMenuBar: true, - transparent: isMac, - vibrancy: 'fullscreen-ui', - visualEffectState: 'active', - titleBarStyle: '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 - // devTools: !app.isPackaged, - } - }) - - mainWindowState.manage(mainWindow) - - mainWindow.webContents.on('context-menu', () => { - const locale = locales[configManager.getLanguage()] - const { common } = locale.translation - - const menu = new Menu() - menu.append(new MenuItem({ label: common.copy, role: 'copy' })) - menu.append(new MenuItem({ label: common.paste, role: 'paste' })) - menu.append(new MenuItem({ label: common.cut, role: 'cut' })) - menu.popup() - }) - - mainWindow.on('ready-to-show', () => { - mainWindow.show() - }) - - mainWindow.webContents.on('will-navigate', (event, url) => { - event.preventDefault() - shell.openExternal(url) - }) - - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) - - 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 }) - }) - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } - - return mainWindow -} - -export function createMinappWindow({ - url, - parent, - windowOptions -}: { - url: string - parent?: BrowserWindow - windowOptions?: Electron.BrowserWindowConstructorOptions -}) { - 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 -}