UNPKG

@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.

442 lines (382 loc) 15.2 kB
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc'; import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain, nativeTheme, screen, } from 'electron'; import os from 'node:os'; import { join } from 'node:path'; import { createLogger } from '@/utils/logger'; import { preloadDir, resourcesDir } from '../const/dir'; import type { App } from './App'; // Create logger const logger = createLogger('core:Browser'); export interface BrowserWindowOpts extends BrowserWindowConstructorOptions { devTools?: boolean; height?: number; /** * URL */ identifier: string; keepAlive?: boolean; parentIdentifier?: string; path: string; showOnInit?: boolean; title?: string; width?: number; } export default class Browser { private app: App; /** * Internal electron window */ private _browserWindow?: BrowserWindow; private stopInterceptHandler; /** * Identifier */ identifier: string; /** * Options at creation */ options: BrowserWindowOpts; /** * Key for storing window state in storeManager */ private readonly windowStateKey: string; /** * Method to expose window externally */ get browserWindow() { return this.retrieveOrInitialize(); } get webContents() { if (this._browserWindow.isDestroyed()) return null; return this._browserWindow.webContents; } /** * Method to construct BrowserWindows object * @param options * @param application */ constructor(options: BrowserWindowOpts, application: App) { logger.debug(`Creating Browser instance: ${options.identifier}`); logger.debug(`Browser options: ${JSON.stringify(options)}`); this.app = application; this.identifier = options.identifier; this.options = options; this.windowStateKey = `windowSize_${this.identifier}`; // Initialization this.retrieveOrInitialize(); } loadUrl = async (path: string) => { const initUrl = this.app.nextServerUrl + path; try { logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`); await this._browserWindow.loadURL(initUrl); logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`); } catch (error) { logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error); // Try to load local error page try { logger.info(`[${this.identifier}] Attempting to load error page...`); await this._browserWindow.loadFile(join(resourcesDir, 'error.html')); logger.info(`[${this.identifier}] Error page loaded successfully.`); // Remove previously set retry listeners to avoid duplicates ipcMain.removeHandler('retry-connection'); logger.debug(`[${this.identifier}] Removed existing retry-connection handler if any.`); // Set retry logic ipcMain.handle('retry-connection', async () => { logger.info(`[${this.identifier}] Retry connection requested for: ${initUrl}`); try { await this._browserWindow?.loadURL(initUrl); logger.info(`[${this.identifier}] Reconnection successful to ${initUrl}`); return { success: true }; } catch (err) { logger.error(`[${this.identifier}] Retry connection failed for ${initUrl}:`, err); // Reload error page try { logger.info(`[${this.identifier}] Reloading error page after failed retry...`); await this._browserWindow?.loadFile(join(resourcesDir, 'error.html')); logger.info(`[${this.identifier}] Error page reloaded.`); } catch (loadErr) { logger.error('[${this.identifier}] Failed to reload error page:', loadErr); } return { error: err.message, success: false }; } }); logger.debug(`[${this.identifier}] Set up retry-connection handler.`); } catch (err) { logger.error(`[${this.identifier}] Failed to load error page:`, err); // If even the error page can't be loaded, at least show a simple error message try { logger.warn(`[${this.identifier}] Attempting to load fallback error HTML string...`); await this._browserWindow.loadURL( 'data:text/html,<html><body><h1>Loading Failed</h1><p>Unable to connect to server, please restart the application</p></body></html>', ); logger.info(`[${this.identifier}] Fallback error HTML string loaded.`); } catch (finalErr) { logger.error(`[${this.identifier}] Unable to display any page:`, finalErr); } } } }; loadPlaceholder = async () => { logger.debug(`[${this.identifier}] Loading splash screen placeholder`); // First load a local HTML loading page await this._browserWindow.loadFile(join(resourcesDir, 'splash.html')); logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`); }; show() { logger.debug(`Showing window: ${this.identifier}`); if (!this._browserWindow.isDestroyed()) this.determineWindowPosition(); this.browserWindow.show(); } private determineWindowPosition() { const { parentIdentifier } = this.options; if (parentIdentifier) { // todo: fix ts type const parentWin = this.app.browserManager.retrieveByIdentifier(parentIdentifier as any); if (parentWin) { logger.debug(`[${this.identifier}] Found parent window: ${parentIdentifier}`); const display = screen.getDisplayNearestPoint(parentWin.browserWindow.getContentBounds()); if (display) { const { workArea: { x, y, width: displayWidth, height: displayHeight }, } = display; const { width, height } = this._browserWindow.getContentBounds(); logger.debug( `[${this.identifier}] Display bounds: x=${x}, y=${y}, width=${displayWidth}, height=${displayHeight}`, ); // Calculate new position const newX = Math.floor(Math.max(x + (displayWidth - width) / 2, x)); const newY = Math.floor(Math.max(y + (displayHeight - height) / 2, y)); logger.debug(`[${this.identifier}] Calculated position: x=${newX}, y=${newY}`); this._browserWindow.setPosition(newX, newY, false); } } } } hide() { logger.debug(`Hiding window: ${this.identifier}`); this.browserWindow.hide(); } close() { logger.debug(`Attempting to close window: ${this.identifier}`); this.browserWindow.close(); } /** * Destroy instance */ destroy() { logger.debug(`Destroying window instance: ${this.identifier}`); this.stopInterceptHandler?.(); this._browserWindow = undefined; } /** * Initialize */ retrieveOrInitialize() { // When there is this window and it has not been destroyed if (this._browserWindow && !this._browserWindow.isDestroyed()) { logger.debug(`[${this.identifier}] Returning existing BrowserWindow instance.`); return this._browserWindow; } const { path, title, width, height, devTools, showOnInit, ...res } = this.options; // Load window state const savedState = this.app.storeManager.get(this.windowStateKey as any) as | { height?: number; width?: number } | undefined; // Keep type for now, but only use w/h logger.info(`Creating new BrowserWindow instance: ${this.identifier}`); logger.debug(`[${this.identifier}] Options for new window: ${JSON.stringify(this.options)}`); logger.debug( `[${this.identifier}] Saved window state (only size used): ${JSON.stringify(savedState)}`, ); const { isWindows11, isWindows } = this.getWindowsVersion(); const isDarkMode = nativeTheme.shouldUseDarkColors; const browserWindow = new BrowserWindow({ ...res, ...(isWindows ? { titleBarStyle: 'hidden', } : {}), ...(isWindows11 ? { backgroundMaterial: isDarkMode ? 'mica' : 'acrylic', vibrancy: 'under-window', visualEffectState: 'active', } : {}), autoHideMenuBar: true, backgroundColor: '#00000000', frame: false, height: savedState?.height || height, // Always create hidden first show: false, title, webPreferences: { // Context isolation environment // https://www.electronjs.org/docs/tutorial/context-isolation contextIsolation: true, preload: join(preloadDir, 'index.js'), }, width: savedState?.width || width, }); this._browserWindow = browserWindow; logger.debug(`[${this.identifier}] BrowserWindow instance created.`); if (isWindows11) this.applyVisualEffects(); logger.debug(`[${this.identifier}] Setting up nextInterceptor.`); this.stopInterceptHandler = this.app.nextInterceptor({ session: browserWindow.webContents.session, }); logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`); this.loadPlaceholder().then(() => { this.loadUrl(path).catch((e) => { logger.error(`[${this.identifier}] Initial loadUrl error for path '${path}':`, e); }); }); // Show devtools if enabled if (devTools) { logger.debug(`[${this.identifier}] Opening DevTools because devTools option is true.`); browserWindow.webContents.openDevTools(); } logger.debug(`[${this.identifier}] Setting up 'ready-to-show' event listener.`); browserWindow.once('ready-to-show', () => { logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`); if (showOnInit) { logger.debug(`Showing window ${this.identifier} because showOnInit is true.`); browserWindow?.show(); } else { logger.debug( `Window ${this.identifier} not shown on 'ready-to-show' because showOnInit is false.`, ); } }); logger.debug(`[${this.identifier}] Setting up 'close' event listener.`); browserWindow.on('close', (e) => { logger.debug(`Window 'close' event triggered for: ${this.identifier}`); logger.debug( `[${this.identifier}] State during close event: isQuiting=${this.app.isQuiting}, keepAlive=${this.options.keepAlive}`, ); // If in application quitting process, allow window to be closed if (this.app.isQuiting) { logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`); // Save state before quitting try { const { width, height } = browserWindow.getBounds(); // Get only width and height const sizeState = { height, width }; logger.debug( `[${this.identifier}] Saving window size on quit: ${JSON.stringify(sizeState)}`, ); this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size } catch (error) { logger.error(`[${this.identifier}] Failed to save window state on quit:`, error); } // Need to clean up intercept handler this.stopInterceptHandler?.(); return; } // Prevent window from being destroyed, just hide it (if marked as keepAlive) if (this.options.keepAlive) { logger.debug( `[${this.identifier}] keepAlive is true, preventing default close and hiding window.`, ); // Optionally save state when hiding if desired, but primary save is on actual close/quit // try { // const bounds = browserWindow.getBounds(); // logger.debug(`[${this.identifier}] Saving window state on hide: ${JSON.stringify(bounds)}`); // this.app.storeManager.set(this.windowStateKey, bounds); // } catch (error) { // logger.error(`[${this.identifier}] Failed to save window state on hide:`, error); // } e.preventDefault(); browserWindow.hide(); } else { // Window is actually closing (not keepAlive) logger.debug( `[${this.identifier}] keepAlive is false, allowing window to close. Saving size...`, // Updated log message ); try { const { width, height } = browserWindow.getBounds(); // Get only width and height const sizeState = { height, width }; logger.debug( `[${this.identifier}] Saving window size on close: ${JSON.stringify(sizeState)}`, ); this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size } catch (error) { logger.error(`[${this.identifier}] Failed to save window state on close:`, error); } // Need to clean up intercept handler this.stopInterceptHandler?.(); } }); logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`); return browserWindow; } moveToCenter() { logger.debug(`Centering window: ${this.identifier}`); this._browserWindow?.center(); } setWindowSize(boundSize: { height?: number; width?: number }) { logger.debug( `Setting window size for ${this.identifier}: width=${boundSize.width}, height=${boundSize.height}`, ); const windowSize = this._browserWindow.getBounds(); this._browserWindow?.setBounds({ height: boundSize.height || windowSize.height, width: boundSize.width || windowSize.width, }); } broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => { if (this._browserWindow.isDestroyed()) return; logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`); this._browserWindow.webContents.send(channel, data); }; applyVisualEffects() { // Windows 11 can use this new API if (this._browserWindow) { logger.debug(`[${this.identifier}] Setting window background material for Windows 11`); const isDarkMode = nativeTheme.shouldUseDarkColors; this._browserWindow?.setBackgroundMaterial(isDarkMode ? 'mica' : 'acrylic'); this._browserWindow?.setVibrancy('under-window'); } } toggleVisible() { logger.debug(`Toggling visibility for window: ${this.identifier}`); if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) { this._browserWindow.hide(); } else { this._browserWindow.show(); this._browserWindow.focus(); } } getWindowsVersion() { if (process.platform !== 'win32') { return { isWindows: false, isWindows10: false, isWindows11: false, version: null, }; } // 获取操作系统版本(如 "10.0.22621") const release = os.release(); const parts = release.split('.'); // 主版本和次版本 const majorVersion = parseInt(parts[0], 10); const minorVersion = parseInt(parts[1], 10); // 构建号是第三部分 const buildNumber = parseInt(parts[2], 10); // Windows 11 的构建号从 22000 开始 const isWindows11 = majorVersion === 10 && minorVersion === 0 && buildNumber >= 22_000; return { buildNumber, isWindows: true, isWindows11, version: release, }; } }