| 1 | import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron' |
| 2 | import log from 'electron-log/main.js' |
| 3 | import { dirname, join } from 'path' |
| 4 | import { fileURLToPath } from 'url' |
| 5 | import { existsSync } from 'fs' |
| 6 | |
| 7 | let tray: Tray | null = null |
| 8 | let hasShownHideBalloon = false |
| 9 | const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 10 | // This module is bundled into the main-process entry, so __dirname is out/main |
| 11 | // at runtime even though the source lives in app/. |
| 12 | const mainOutputDir = __dirname |
| 13 | |
| 14 | function resolveTrayIconPaths(): string[] { |
| 15 | const iconPaths: string[] = [] |
| 16 | const fileNames = ['16x16.png', '32x32.png', 'icon.ico'] |
| 17 | const roots = [ |
| 18 | join(process.resourcesPath, 'icons'), |
| 19 | join(process.resourcesPath, 'build/icons'), |
| 20 | join(process.resourcesPath, 'app.asar.unpacked/build/icons'), |
| 21 | join(process.resourcesPath, 'app.asar.unpacked/resources/icons'), |
| 22 | join(mainOutputDir, '../../resources/icons'), |
| 23 | join(mainOutputDir, '../../build/icons'), |
| 24 | join(process.cwd(), 'build/icons') |
| 25 | ] |
| 26 | |
| 27 | for (const root of roots) { |
| 28 | for (const fileName of fileNames) { |
| 29 | const iconPath = join(root, fileName) |
| 30 | if (existsSync(iconPath)) iconPaths.push(iconPath) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | if (process.platform === 'win32' && existsSync(process.execPath)) { |
| 35 | iconPaths.push(process.execPath) |
| 36 | } |
| 37 | |
| 38 | return Array.from(new Set(iconPaths)) |
| 39 | } |
| 40 | |
| 41 | function showMainWindow(mainWindow: BrowserWindow | null): void { |
| 42 | if (!mainWindow || mainWindow.isDestroyed()) return |
| 43 | if (mainWindow.isMinimized()) mainWindow.restore() |
| 44 | mainWindow.show() |
| 45 | mainWindow.focus() |
| 46 | } |
| 47 | |
| 48 | export function createTray(mainWindow: BrowserWindow | null): boolean { |
| 49 | const iconPaths = resolveTrayIconPaths() |
| 50 | if (iconPaths.length === 0) { |
| 51 | log.warn('[tray] icon not found') |
| 52 | return false |
| 53 | } |
| 54 | |
| 55 | let selectedIconPath: string | null = null |
| 56 | let trayIcon: Electron.NativeImage | null = null |
| 57 | for (const iconPath of iconPaths) { |
| 58 | const icon = nativeImage.createFromPath(iconPath) |
| 59 | if (icon.isEmpty()) { |
| 60 | log.warn('[tray] candidate icon is empty', { iconPath }) |
| 61 | continue |
| 62 | } |
| 63 | selectedIconPath = iconPath |
| 64 | trayIcon = |
| 65 | process.platform === 'win32' && !iconPath.toLowerCase().endsWith('.exe') |
| 66 | ? icon.resize({ width: 16, height: 16 }) |
| 67 | : icon |
| 68 | break |
| 69 | } |
| 70 | |
| 71 | if (!selectedIconPath || !trayIcon || trayIcon.isEmpty()) { |
| 72 | log.warn('[tray] all candidate icons are empty', { iconPaths }) |
| 73 | return false |
| 74 | } |
| 75 | |
| 76 | try { |
| 77 | tray = new Tray(trayIcon) |
| 78 | } catch (error) { |
| 79 | log.warn('[tray] create failed', { |
| 80 | iconPath: selectedIconPath, |
| 81 | message: error instanceof Error ? error.message : String(error) |
| 82 | }) |
| 83 | return false |
| 84 | } |
| 85 | tray.setToolTip('Oh My PPT') |
| 86 | |
| 87 | const contextMenu = Menu.buildFromTemplate([ |
| 88 | { |
| 89 | label: '显示主窗口', |
| 90 | click: () => { |
| 91 | showMainWindow(mainWindow) |
| 92 | } |
| 93 | }, |
| 94 | { type: 'separator' }, |
| 95 | { |
| 96 | label: '退出', |
| 97 | click: () => { |
| 98 | if (mainWindow && !mainWindow.isDestroyed()) { |
| 99 | mainWindow.destroy() |
| 100 | } |
| 101 | app.quit() |
| 102 | } |
| 103 | } |
| 104 | ]) |
| 105 | tray.setContextMenu(contextMenu) |
| 106 | |
| 107 | tray.on('click', () => { |
| 108 | showMainWindow(mainWindow) |
| 109 | }) |
| 110 | |
| 111 | log.info('[tray] created', { iconPath: selectedIconPath }) |
| 112 | return true |
| 113 | } |
| 114 | |
| 115 | export function showTrayHideBalloon(): void { |
| 116 | if (process.platform !== 'win32' || !tray || tray.isDestroyed() || hasShownHideBalloon) return |
| 117 | hasShownHideBalloon = true |
| 118 | tray.displayBalloon({ |
| 119 | title: 'Oh My PPT 已最小化到托盘', |
| 120 | content: '点击通知区域中的 Oh My PPT 图标可恢复窗口。', |
| 121 | iconType: 'info', |
| 122 | largeIcon: false, |
| 123 | noSound: true |
| 124 | }) |
| 125 | } |
| 126 | |
| 127 | export function destroyTray(): void { |
| 128 | if (tray) { |
| 129 | tray.destroy() |
| 130 | tray = null |
| 131 | } |
| 132 | } |
| 133 |