@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
413 lines (338 loc) • 13 kB
text/typescript
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
import { Session, app, ipcMain, protocol } from 'electron';
import { macOS, windows } from 'electron-is';
import os from 'node:os';
import { join } from 'node:path';
import { name } from '@/../../package.json';
import { buildDir, nextStandaloneDir } from '@/const/dir';
import { isDev } from '@/const/env';
import { IControlModule } from '@/controllers';
import { IServiceModule } from '@/services';
import FileService from '@/services/fileSrv';
import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { createLogger } from '@/utils/logger';
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
import BrowserManager from './BrowserManager';
import { I18nManager } from './I18nManager';
import { IoCContainer } from './IoCContainer';
import MenuManager from './MenuManager';
import { ShortcutManager } from './ShortcutManager';
import { StaticFileServerManager } from './StaticFileServerManager';
import { StoreManager } from './StoreManager';
import TrayManager from './TrayManager';
import { UpdaterManager } from './UpdaterManager';
const logger = createLogger('core:App');
export type IPCEventMap = Map<string, { controller: any; methodName: string }>;
export type ShortcutMethodMap = Map<string, () => Promise<void>>;
type Class<T> = new (...args: any[]) => T;
const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
export class App {
nextServerUrl = 'http://localhost:3015';
browserManager: BrowserManager;
menuManager: MenuManager;
i18n: I18nManager;
storeManager: StoreManager;
updaterManager: UpdaterManager;
shortcutManager: ShortcutManager;
trayManager: TrayManager;
staticFileServerManager: StaticFileServerManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
/**
* whether app is in quiting
*/
isQuiting: boolean = false;
get appStoragePath() {
const storagePath = this.storeManager.get('storagePath');
if (!storagePath) {
throw new Error('Storage path not found in store');
}
return storagePath;
}
constructor() {
logger.info('----------------------------------------------');
// Log system information
logger.info(` OS: ${os.platform()} (${os.arch()})`);
logger.info(` CPU: ${os.cpus().length} cores`);
logger.info(` RAM: ${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`);
logger.info(`PATH: ${app.getAppPath()}`);
logger.info(` lng: ${app.getLocale()}`);
logger.info('----------------------------------------------');
logger.info('Starting LobeHub...');
logger.debug('Initializing App');
// Initialize store manager
this.storeManager = new StoreManager(this);
// load controllers
const controllers: IControlModule[] = importAll(
(import.meta as any).glob('@/controllers/*Ctr.ts', { eager: true }),
);
logger.debug(`Loading ${controllers.length} controllers`);
controllers.forEach((controller) => this.addController(controller));
// load services
const services: IServiceModule[] = importAll(
(import.meta as any).glob('@/services/*Srv.ts', { eager: true }),
);
logger.debug(`Loading ${services.length} services`);
services.forEach((service) => this.addService(service));
this.initializeIPCEvents();
this.i18n = new I18nManager(this);
this.browserManager = new BrowserManager(this);
this.menuManager = new MenuManager(this);
this.updaterManager = new UpdaterManager(this);
this.shortcutManager = new ShortcutManager(this);
this.trayManager = new TrayManager(this);
this.staticFileServerManager = new StaticFileServerManager(this);
// register the schema to interceptor url
// it should register before app ready
this.registerNextHandler();
// 统一处理 before-quit 事件
app.on('before-quit', this.handleBeforeQuit);
logger.info('App initialization completed');
}
bootstrap = async () => {
logger.info('Bootstrapping application');
// make single instance
const isSingle = app.requestSingleInstanceLock();
if (!isSingle) {
logger.info('Another instance is already running, exiting');
app.exit(0);
}
this.initDevBranding();
// ==============
await this.ipcServer.start();
logger.debug('IPC server started');
// Initialize app
await this.makeAppReady();
// Initialize i18n. Note: app.getLocale() must be called after app.whenReady() to get the correct value
await this.i18n.init();
this.menuManager.initialize();
// Initialize static file manager
await this.staticFileServerManager.initialize();
// Initialize global shortcuts: globalShortcut must be called after app.whenReady()
this.shortcutManager.initialize();
this.browserManager.initializeBrowsers();
// Initialize tray manager
if (process.platform === 'win32') {
this.trayManager.initializeTrays();
}
// Initialize updater manager
await this.updaterManager.initialize();
// Set global application exit state
this.isQuiting = false;
app.on('window-all-closed', () => {
if (windows()) {
logger.info('All windows closed, quitting application (Windows)');
app.quit();
}
});
app.on('activate', this.onActivate);
logger.info('Application bootstrap completed');
};
getService<T>(serviceClass: Class<T>): T {
return this.services.get(serviceClass);
}
getController<T>(controllerClass: Class<T>): T {
return this.controllers.get(controllerClass);
}
private onActivate = () => {
logger.debug('Application activated');
this.browserManager.showMainWindow();
};
/**
* Call beforeAppReady method on all controllers before the application is ready
*/
private makeAppReady = async () => {
logger.debug('Preparing application ready state');
this.controllers.forEach((controller) => {
if (typeof controller.beforeAppReady === 'function') {
try {
controller.beforeAppReady();
} catch (error) {
logger.error(`Error in controller.beforeAppReady:`, error);
console.error(`[App] Error in controller.beforeAppReady:`, error);
}
}
});
// refs: https://github.com/lobehub/lobe-chat/pull/7883
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
app.commandLine.appendSwitch('gtk-version', '3');
app.commandLine.appendSwitch('enable-features', this.chromeFlags.join(','));
logger.debug('Waiting for app to be ready');
await app.whenReady();
logger.debug('Application ready');
this.controllers.forEach((controller) => {
if (typeof controller.afterAppReady === 'function') {
try {
controller.afterAppReady();
} catch (error) {
logger.error(`Error in controller.afterAppReady:`, error);
console.error(`[App] Error in controller.beforeAppReady:`, error);
}
}
});
logger.info('Application ready state completed');
};
// ============= helper ============= //
/**
* all controllers in app
*/
private controllers = new Map<Class<any>, any>();
/**
* all services in app
*/
private services = new Map<Class<any>, any>();
private ipcServer: ElectronIPCServer;
/**
* events dispatched from webview layer
*/
private ipcClientEventMap: IPCEventMap = new Map();
private ipcServerEventMap: IPCEventMap = new Map();
shortcutMethodMap: ShortcutMethodMap = new Map();
/**
* use in next router interceptor in prod browser render
*/
nextInterceptor: (params: { session: Session }) => () => void;
/**
* Collection of unregister functions for custom request handlers
*/
private customHandlerUnregisterFns: Array<() => void> = [];
/**
* Function to register custom request handler
*/
private registerCustomHandlerFn?: (handler: CustomRequestHandler) => () => void;
/**
* Register custom request handler
* @param handler Custom request handler function
* @returns Function to unregister the handler
*/
registerRequestHandler = (handler: CustomRequestHandler): (() => void) => {
if (!this.registerCustomHandlerFn) {
logger.warn('Custom request handler registration is not available');
return () => {};
}
logger.debug('Registering custom request handler');
const unregisterFn = this.registerCustomHandlerFn(handler);
this.customHandlerUnregisterFns.push(unregisterFn);
return () => {
unregisterFn();
const index = this.customHandlerUnregisterFns.indexOf(unregisterFn);
if (index !== -1) {
this.customHandlerUnregisterFns.splice(index, 1);
}
};
};
/**
* Unregister all custom request handlers
*/
unregisterAllRequestHandlers = () => {
this.customHandlerUnregisterFns.forEach((unregister) => unregister());
this.customHandlerUnregisterFns = [];
};
private addController = (ControllerClass: IControlModule) => {
const controller = new ControllerClass(this);
this.controllers.set(ControllerClass, controller);
IoCContainer.controllers.get(ControllerClass)?.forEach((event) => {
if (event.mode === 'client') {
// Store all objects from event decorator in ipcClientEventMap
this.ipcClientEventMap.set(event.name, {
controller,
methodName: event.methodName,
});
}
if (event.mode === 'server') {
// Store all objects from event decorator in ipcServerEventMap
this.ipcServerEventMap.set(event.name, {
controller,
methodName: event.methodName,
});
}
});
IoCContainer.shortcuts.get(ControllerClass)?.forEach((shortcut) => {
this.shortcutMethodMap.set(shortcut.name, async () => {
controller[shortcut.methodName]();
});
});
};
private addService = (ServiceClass: IServiceModule) => {
const service = new ServiceClass(this);
this.services.set(ServiceClass, service);
};
private initDevBranding = () => {
if (!isDev) return;
logger.debug('Setting up dev branding');
app.setName('lobehub-desktop-dev');
if (macOS()) {
app.dock!.setIcon(join(buildDir, 'icon-dev.png'));
}
};
private registerNextHandler() {
logger.debug('Registering Next.js handler');
const handler = createHandler({
debug: true,
localhostUrl: this.nextServerUrl,
protocol,
standaloneDir: nextStandaloneDir,
});
// Log output based on development or production mode
if (isDev) {
logger.info(
`Development mode: Custom request handler enabled, but Next.js interception disabled`,
);
} else {
logger.info(
`Production mode: ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`,
);
}
this.nextInterceptor = handler.createInterceptor;
// Save custom handler registration function
if (handler.registerCustomHandler) {
this.registerCustomHandlerFn = handler.registerCustomHandler;
logger.debug('Custom request handler registration is available');
} else {
logger.warn('Custom request handler registration is not available');
}
}
private initializeIPCEvents() {
logger.debug('Initializing IPC events');
// Register batch controller client events for render side consumption
this.ipcClientEventMap.forEach((eventInfo, key) => {
const { controller, methodName } = eventInfo;
ipcMain.handle(key, async (e, data) => {
// 从 WebContents 获取对应的 BrowserWindow id
const senderIdentifier = this.browserManager.getIdentifierByWebContents(e.sender);
try {
return await controller[methodName](data, {
identifier: senderIdentifier,
} as IpcClientEventSender);
} catch (error) {
logger.error(`Error handling IPC event ${key}:`, error);
return { error: error.message };
}
});
});
// Batch register server events from controllers for next server consumption
const ipcServerEvents = {} as ElectronIPCEventHandler;
this.ipcServerEventMap.forEach((eventInfo, key) => {
const { controller, methodName } = eventInfo;
ipcServerEvents[key] = async (payload) => {
try {
return await controller[methodName](payload);
} catch (error) {
return { error: error.message };
}
};
});
this.ipcServer = new ElectronIPCServer(name, ipcServerEvents);
}
// 新增 before-quit 处理函数
private handleBeforeQuit = () => {
logger.info('Application is preparing to quit');
this.isQuiting = true;
// 销毁托盘
if (process.platform === 'win32') {
this.trayManager.destroyAll();
}
// 执行清理操作
this.staticFileServerManager.destroy();
this.unregisterAllRequestHandlers();
};
}