💄 style: add windows control and tray (#7665)

* add windows support

* add windows close support

* improve

* fix

* FIX

* improve builder

* improve windows icon
This commit is contained in:
Arvin Xu
2025-05-01 16:42:26 +08:00
committed by GitHub
parent 6d8ca6ab76
commit c5f3d13c14
24 changed files with 746 additions and 19 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -79,10 +79,10 @@ const config = {
},
npmRebuild: true,
nsis: {
allowToChangeInstallationDirectory: true,
artifactName: '${productName}-${version}-setup.${ext}',
createDesktopShortcut: 'always',
// allowToChangeInstallationDirectory: true,
// oneClick: false,
oneClick: false,
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
},

View File

@@ -23,7 +23,6 @@
@media (prefers-color-scheme: dark) {
body {
color: #f5f5f5;
background-color: #121212;
}
.error-message {
color: #f5f5f5;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -2,6 +2,7 @@ import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
import { extractSubPath, findMatchingRoute } from '~common/routes';
import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { ControllerModule, ipcClientEvent, shortcut } from './index';
@@ -26,6 +27,21 @@ export default class BrowserWindowsCtr extends ControllerModule {
}
}
@ipcClientEvent('closeWindow')
closeWindow(data: undefined, sender: IpcClientEventSender) {
this.app.browserManager.closeWindow(sender.identifier);
}
@ipcClientEvent('minimizeWindow')
minimizeWindow(data: undefined, sender: IpcClientEventSender) {
this.app.browserManager.minimizeWindow(sender.identifier);
}
@ipcClientEvent('maximizeWindow')
maximizeWindow(data: undefined, sender: IpcClientEventSender) {
this.app.browserManager.maximizeWindow(sender.identifier);
}
/**
* Handle route interception requests
* Responsible for handling route interception requests from the renderer process

View File

@@ -0,0 +1,109 @@
import {
ShowTrayNotificationParams,
UpdateTrayIconParams,
UpdateTrayTooltipParams,
} from '@lobechat/electron-client-ipc';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent, shortcut } from './index';
// 创建日志记录器
const logger = createLogger('controllers:TrayMenuCtr');
export default class TrayMenuCtr extends ControllerModule {
/**
* 使用快捷键切换窗口可见性
*/
@shortcut('toggleMainWindow')
async toggleMainWindow() {
logger.debug('通过快捷键切换主窗口可见性');
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.toggleVisible();
}
/**
* 显示托盘气泡通知
* @param options 气泡选项
* @returns 操作结果
*/
@ipcClientEvent('showTrayNotification')
async showNotification(options: ShowTrayNotificationParams) {
logger.debug('显示托盘气泡通知');
if (process.platform === 'win32') {
const mainTray = this.app.trayManager.getMainTray();
if (mainTray) {
mainTray.displayBalloon({
content: options.content,
iconType: options.iconType || 'info',
title: options.title,
});
return { success: true };
}
}
return {
error: '托盘通知仅在 Windows 平台支持',
success: false
};
}
/**
* 更新托盘图标
* @param options 图标选项
* @returns 操作结果
*/
@ipcClientEvent('updateTrayIcon')
async updateTrayIcon(options: UpdateTrayIconParams) {
logger.debug('更新托盘图标');
if (process.platform === 'win32') {
const mainTray = this.app.trayManager.getMainTray();
if (mainTray && options.iconPath) {
try {
mainTray.updateIcon(options.iconPath);
return { success: true };
} catch (error) {
logger.error('更新托盘图标失败:', error);
return {
error: String(error),
success: false
};
}
}
}
return {
error: '托盘功能仅在 Windows 平台支持',
success: false
};
}
/**
* 更新托盘提示文本
* @param options 提示文本选项
* @returns 操作结果
*/
@ipcClientEvent('updateTrayTooltip')
async updateTrayTooltip(options: UpdateTrayTooltipParams) {
logger.debug('更新托盘提示文本');
if (process.platform === 'win32') {
const mainTray = this.app.trayManager.getMainTray();
if (mainTray && options.tooltip) {
mainTray.updateTooltip(options.tooltip);
return { success: true };
}
}
return {
error: '托盘功能仅在 Windows 平台支持',
success: false
};
}
}

View File

@@ -9,6 +9,7 @@ import { buildDir, nextStandaloneDir } from '@/const/dir';
import { isDev } from '@/const/env';
import { IControlModule } from '@/controllers';
import { IServiceModule } from '@/services';
import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { createLogger } from '@/utils/logger';
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
@@ -18,6 +19,7 @@ import { IoCContainer } from './IoCContainer';
import MenuManager from './MenuManager';
import { ShortcutManager } from './ShortcutManager';
import { StoreManager } from './StoreManager';
import TrayManager from './TrayManager';
import { UpdaterManager } from './UpdaterManager';
const logger = createLogger('core:App');
@@ -38,6 +40,7 @@ export class App {
storeManager: StoreManager;
updaterManager: UpdaterManager;
shortcutManager: ShortcutManager;
trayManager: TrayManager;
/**
* whether app is in quiting
@@ -92,6 +95,7 @@ export class App {
this.menuManager = new MenuManager(this);
this.updaterManager = new UpdaterManager(this);
this.shortcutManager = new ShortcutManager(this);
this.trayManager = new TrayManager(this);
// register the schema to interceptor url
// it should register before app ready
@@ -130,6 +134,11 @@ export class App {
this.browserManager.initializeBrowsers();
// Initialize tray manager
if (process.platform === 'win32') {
this.trayManager.initializeTrays();
}
// Initialize updater manager
await this.updaterManager.initialize();
@@ -340,9 +349,13 @@ export class App {
this.ipcClientEventMap.forEach((eventInfo, key) => {
const { controller, methodName } = eventInfo;
ipcMain.handle(key, async (e, ...data) => {
ipcMain.handle(key, async (e, data) => {
// 从 WebContents 获取对应的 BrowserWindow id
const senderIdentifier = this.browserManager.getIdentifierByWebContents(e.sender);
try {
return await controller[methodName](...data);
return await controller[methodName](data, {
identifier: senderIdentifier,
} as IpcClientEventSender);
} catch (error) {
logger.error(`Error handling IPC event ${key}:`, error);
return { error: error.message };
@@ -370,7 +383,13 @@ export class App {
// 新增 before-quit 处理函数
private handleBeforeQuit = () => {
this.isQuiting = true; // 首先设置标志
logger.info('Application is preparing to quit');
this.isQuiting = true;
// 销毁托盘
if (process.platform === 'win32') {
this.trayManager.destroyAll();
}
// 执行清理操作
this.unregisterAllRequestHandlers();

View File

@@ -184,12 +184,11 @@ export default class Browser {
const browserWindow = new BrowserWindow({
...res,
height: savedState?.height || height,
// Always create hidden first
show: false,
// Always create hidden first
title,
transparent: true,
@@ -199,11 +198,7 @@ export default class Browser {
// https://www.electronjs.org/docs/tutorial/context-isolation
contextIsolation: true,
preload: join(preloadDir, 'index.js'),
// devTools: isDev,
},
// Use saved state if available, otherwise use options. Do not set x/y
// x: savedState?.x, // Don't restore x
// y: savedState?.y, // Don't restore y
width: savedState?.width || width,
});
@@ -215,6 +210,7 @@ export default class Browser {
session: browserWindow.webContents.session,
});
console.log('platform:',process.platform);
// Windows 11 can use this new API
if (process.platform === 'win32' && browserWindow.setBackgroundMaterial) {
logger.debug(`[${this.identifier}] Setting window background material for Windows 11`);

View File

@@ -1,4 +1,5 @@
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import { WebContents } from 'electron';
import { createLogger } from '@/utils/logger';
@@ -15,6 +16,8 @@ export default class BrowserManager {
browsers: Map<AppBrowsersIdentifiers, Browser> = new Map();
private webContentsMap = new Map<WebContents, AppBrowsersIdentifiers>();
constructor(app: App) {
logger.debug('Initializing BrowserManager');
this.app = app;
@@ -147,8 +150,40 @@ export default class BrowserManager {
logger.debug(`Creating new browser: ${options.identifier}`);
browser = new Browser(options, this.app);
this.browsers.set(options.identifier as AppBrowsersIdentifiers, browser);
const identifier = options.identifier as AppBrowsersIdentifiers;
this.browsers.set(identifier, browser);
// 记录 WebContents 和 identifier 的映射
this.webContentsMap.set(browser.browserWindow.webContents, identifier);
// 当窗口关闭时清理映射
browser.browserWindow.on('closed', () => {
this.webContentsMap.delete(browser.browserWindow.webContents);
});
return browser;
}
closeWindow(identifier: string) {
const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
browser?.close();
}
minimizeWindow(identifier: string) {
const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
browser?.browserWindow.minimize();
}
maximizeWindow(identifier: string) {
const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
if (browser.browserWindow.isMaximized()) {
browser?.browserWindow.unmaximize();
} else {
browser?.browserWindow.maximize();
}
}
getIdentifierByWebContents(webContents: WebContents): AppBrowsersIdentifiers | null {
return this.webContentsMap.get(webContents) || null;
}
}

View File

@@ -0,0 +1,231 @@
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import {
DisplayBalloonOptions,
Tray as ElectronTray,
Menu,
MenuItemConstructorOptions,
app,
nativeImage,
} from 'electron';
import { join } from 'node:path';
import { resourcesDir } from '@/const/dir';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
// 创建日志记录器
const logger = createLogger('core:Tray');
export interface TrayOptions {
/**
* 托盘图标路径(相对于资源目录)
*/
iconPath: string;
/**
* 托盘标识符
*/
identifier: string;
/**
* 托盘提示文本
*/
tooltip?: string;
}
export default class Tray {
private app: App;
/**
* 内部 Electron 托盘
*/
private _tray?: ElectronTray;
/**
* 标识符
*/
identifier: string;
/**
* 创建时的选项
*/
options: TrayOptions;
/**
* 获取托盘实例
*/
get tray() {
return this.retrieveOrInitialize();
}
/**
* 构造托盘对象
* @param options 托盘选项
* @param application 应用实例
*/
constructor(options: TrayOptions, application: App) {
logger.debug(`创建托盘实例: ${options.identifier}`);
logger.debug(`托盘选项: ${JSON.stringify(options)}`);
this.app = application;
this.identifier = options.identifier;
this.options = options;
// 初始化
this.retrieveOrInitialize();
}
/**
* 初始化托盘
*/
retrieveOrInitialize() {
// 如果托盘已存在且未被销毁,则返回
if (this._tray) {
logger.debug(`[${this.identifier}] 返回现有托盘实例`);
return this._tray;
}
const { iconPath, tooltip } = this.options;
// 加载托盘图标
logger.info(`创建新的托盘实例: ${this.identifier}`);
const iconFile = join(resourcesDir, iconPath);
logger.debug(`[${this.identifier}] 加载图标: ${iconFile}`);
try {
const icon = nativeImage.createFromPath(iconFile);
this._tray = new ElectronTray(icon);
// 设置工具提示
if (tooltip) {
logger.debug(`[${this.identifier}] 设置提示文本: ${tooltip}`);
this._tray.setToolTip(tooltip);
}
// 设置默认上下文菜单
this.setContextMenu();
// 设置点击事件
this._tray.on('click', () => {
logger.debug(`[${this.identifier}] 托盘被点击`);
this.onClick();
});
logger.debug(`[${this.identifier}] 托盘实例创建完成`);
return this._tray;
} catch (error) {
logger.error(`[${this.identifier}] 创建托盘失败:`, error);
throw error;
}
}
/**
* 设置托盘上下文菜单
* @param template 菜单模板,如果未提供则使用默认模板
*/
setContextMenu(template?: MenuItemConstructorOptions[]) {
logger.debug(`[${this.identifier}] 设置托盘上下文菜单`);
// 如果未提供模板,使用默认菜单
const defaultTemplate: MenuItemConstructorOptions[] = template || [
{
click: () => {
logger.debug(`[${this.identifier}] 菜单项 "显示主窗口" 被点击`);
this.app.browserManager.showMainWindow();
},
label: '显示主窗口',
},
{ type: 'separator' },
{
click: () => {
logger.debug(`[${this.identifier}] 菜单项 "退出" 被点击`);
app.quit();
},
label: '退出',
},
];
const contextMenu = Menu.buildFromTemplate(defaultTemplate);
this._tray?.setContextMenu(contextMenu);
logger.debug(`[${this.identifier}] 托盘上下文菜单已设置`);
}
/**
* 处理托盘点击事件
*/
onClick() {
logger.debug(`[${this.identifier}] 处理托盘点击事件`);
const mainWindow = this.app.browserManager.getMainWindow();
if (mainWindow) {
if (mainWindow.browserWindow.isVisible() && mainWindow.browserWindow.isFocused()) {
logger.debug(`[${this.identifier}] 主窗口已可见且聚焦,现在隐藏它`);
mainWindow.hide();
} else {
logger.debug(`[${this.identifier}] 显示并聚焦主窗口`);
mainWindow.show();
mainWindow.browserWindow.focus();
}
}
}
/**
* 更新托盘图标
* @param iconPath 新图标路径(相对于资源目录)
*/
updateIcon(iconPath: string) {
logger.debug(`[${this.identifier}] 更新图标: ${iconPath}`);
try {
const iconFile = join(resourcesDir, iconPath);
const icon = nativeImage.createFromPath(iconFile);
this._tray?.setImage(icon);
this.options.iconPath = iconPath;
logger.debug(`[${this.identifier}] 图标已更新`);
} catch (error) {
logger.error(`[${this.identifier}] 更新图标失败:`, error);
}
}
/**
* 更新提示文本
* @param tooltip 新提示文本
*/
updateTooltip(tooltip: string) {
logger.debug(`[${this.identifier}] 更新提示文本: ${tooltip}`);
this._tray?.setToolTip(tooltip);
this.options.tooltip = tooltip;
}
/**
* 显示气泡通知(仅在 Windows 上支持)
* @param options 气泡选项
*/
displayBalloon(options: DisplayBalloonOptions) {
if (process.platform === 'win32' && this._tray) {
logger.debug(`[${this.identifier}] 显示气泡通知: ${JSON.stringify(options)}`);
this._tray.displayBalloon(options);
} else {
logger.debug(`[${this.identifier}] 气泡通知仅在 Windows 上支持`);
}
}
/**
* 广播事件
*/
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
logger.debug(`向托盘 ${this.identifier} 广播, 频道: ${channel}`);
// 可以通过 App 实例的 browserManager 将消息转发到主窗口
this.app.browserManager.getMainWindow()?.broadcast(channel, data);
};
/**
* 销毁托盘实例
*/
destroy() {
logger.debug(`销毁托盘实例: ${this.identifier}`);
if (this._tray) {
this._tray.destroy();
this._tray = undefined;
}
}
}

View File

@@ -0,0 +1,131 @@
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import { name } from '@/../../package.json';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
import Tray, { TrayOptions } from './Tray';
// 创建日志记录器
const logger = createLogger('core:TrayManager');
/**
* 托盘标识符类型
*/
export type TrayIdentifiers = 'main';
export default class TrayManager {
app: App;
/**
* 存储所有托盘实例
*/
trays: Map<TrayIdentifiers, Tray> = new Map();
/**
* 构造方法
* @param app 应用实例
*/
constructor(app: App) {
logger.debug('初始化 TrayManager');
this.app = app;
}
/**
* 初始化所有托盘
*/
initializeTrays() {
logger.debug('初始化应用托盘');
// 初始化主托盘
this.initializeMainTray();
}
/**
* 获取主托盘
*/
getMainTray() {
return this.retrieveByIdentifier('main');
}
/**
* 初始化主托盘
*/
initializeMainTray() {
logger.debug('初始化主托盘');
return this.retrieveOrInitialize({
iconPath: 'tray-icon.png',
identifier: 'main', // 使用应用图标,需要确保资源目录中有此文件
tooltip: name, // 可以使用 app.getName() 或本地化字符串
});
}
/**
* 通过标识符获取托盘实例
* @param identifier 托盘标识符
*/
retrieveByIdentifier(identifier: TrayIdentifiers) {
logger.debug(`通过标识符获取托盘: ${identifier}`);
return this.trays.get(identifier);
}
/**
* 向所有托盘广播消息
* @param event 事件名称
* @param data 事件数据
*/
broadcastToAllTrays = <T extends MainBroadcastEventKey>(
event: T,
data: MainBroadcastParams<T>,
) => {
logger.debug(`向所有托盘广播事件 ${event}`);
this.trays.forEach((tray) => {
tray.broadcast(event, data);
});
};
/**
* 向指定托盘广播消息
* @param identifier 托盘标识符
* @param event 事件名称
* @param data 事件数据
*/
broadcastToTray = <T extends MainBroadcastEventKey>(
identifier: TrayIdentifiers,
event: T,
data: MainBroadcastParams<T>,
) => {
logger.debug(`向托盘 ${identifier} 广播事件 ${event}`);
this.trays.get(identifier)?.broadcast(event, data);
};
/**
* 获取或创建托盘实例
* @param options 托盘选项
*/
private retrieveOrInitialize(options: TrayOptions) {
let tray = this.trays.get(options.identifier as TrayIdentifiers);
if (tray) {
logger.debug(`获取现有托盘: ${options.identifier}`);
return tray;
}
logger.debug(`创建新托盘: ${options.identifier}`);
tray = new Tray(options, this.app);
this.trays.set(options.identifier as TrayIdentifiers, tray);
return tray;
}
/**
* 销毁所有托盘
*/
destroyAll() {
logger.debug('销毁所有托盘');
this.trays.forEach((tray) => {
tray.destroy();
});
this.trays.clear();
}
}

View File

@@ -0,0 +1,3 @@
export interface IpcClientEventSender {
identifier: string;
}