mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix(desktop): prevent duplicate CORS headers in response (#11350)
* refactor: reduce unused code Signed-off-by: Innei <tukon479@gmail.com> * 🐛 fix(desktop): prevent duplicate CORS headers in response Only add CORS headers if they don't already exist in the server response. This fixes issues with CDN resources (like cdn.jsdelivr.net) that already return CORS headers, causing "multiple values" errors. Fixes LOBE-2765 * 🔧 refactor(desktop): remove IpcServerMethod decorator and related metadata This update simplifies the IPC method handling by removing the IpcServerMethod decorator and its associated metadata management. The changes include updates to documentation and code references, ensuring a cleaner and more maintainable IPC implementation. No functional changes were introduced, but the codebase is now more streamlined for future development. Signed-off-by: Innei <tukon479@gmail.com> * ✨ feat(desktop): introduce HTTP headers utility functions Added a new utility module for managing HTTP response headers in Electron, addressing case sensitivity issues. This includes functions to set, get, check existence, and delete headers. Updated the Browser class to utilize these utilities for setting CORS headers, ensuring no duplicates are present. This enhancement improves code maintainability and simplifies header management in the application. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -37,10 +37,10 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
- 位置:`apps/desktop/src/main/controllers/`
|
||||
- 示例:创建 `NewFeatureCtr.ts`
|
||||
- 需继承 `ControllerModule`,并设置 `static readonly groupName`(例如 `static override readonly groupName = 'newFeature';`)
|
||||
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors`(或 `controllerServerIpcConstructors`)中注册,保证类型推导与自动装配
|
||||
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors` 中注册,保证类型推导与自动装配
|
||||
|
||||
2. **定义 IPC 事件处理器**
|
||||
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道,或使用 `@IpcServerMethod()` 声明仅供 Next.js 服务器调用的 IPC
|
||||
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道
|
||||
- 通道名称基于 `groupName.methodName` 自动生成,不再手动拼接字符串
|
||||
- 处理函数可通过 `getIpcContext()` 获取 `sender`、`event` 等上下文信息,并按照需要返回结构化结果
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ alwaysApply: false
|
||||
5. **实现后端逻辑 (Controller / IPC Handler):**
|
||||
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
|
||||
* **操作:**
|
||||
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`/`IpcServerMethod`、参数类型等)。
|
||||
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`、参数类型等)。
|
||||
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
|
||||
* 使用 `@IpcMethod()` 或 `@IpcServerMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
|
||||
* 使用 `@IpcMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
|
||||
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
|
||||
* 实现核心业务逻辑:
|
||||
* 进行必要的输入验证。
|
||||
|
||||
@@ -269,7 +269,7 @@ export class ShortcutManager {
|
||||
- 注入 App 实例
|
||||
|
||||
```typescript
|
||||
import { ControllerModule, IpcMethod, IpcServerMethod } from '@/controllers'
|
||||
import { ControllerModule, IpcMethod } from '@/controllers'
|
||||
|
||||
export class ControllerModule implements IControllerModule {
|
||||
constructor(public app: App) {
|
||||
@@ -284,11 +284,6 @@ export class BrowserWindowsCtr extends ControllerModule {
|
||||
openSettingsWindow(params?: OpenSettingsWindowOptions) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
handleServerCommand(payload: any) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -183,16 +183,15 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
|
||||
#### 🔌 Dependency Injection & Event System
|
||||
|
||||
- **IoC Container** - WeakMap-based container for decorated controller methods
|
||||
- **Typed IPC Decorators** - `@IpcMethod` and `@IpcServerMethod` wire controller methods into type-safe channels
|
||||
- **Typed IPC Decorators** - `@IpcMethod` wires controller methods into type-safe channels
|
||||
- **Automatic Event Mapping** - Events registered during controller loading
|
||||
- **Service Locator** - Type-safe service and controller retrieval
|
||||
|
||||
##### 🧠 Type-Safe IPC Flow
|
||||
|
||||
- **Async Context Propagation** - `src/main/utils/ipc/base.ts` captures the `IpcContext` with `AsyncLocalStorage`, so controller logic can call `getIpcContext()` anywhere inside an IPC handler without explicitly threading arguments.
|
||||
- **Service Constructors Registry** - `src/main/controllers/registry.ts` exports `controllerIpcConstructors`, `DesktopIpcServices`, and `DesktopServerIpcServices`, enabling automatic typing of both renderer and server IPC proxies.
|
||||
- **Service Constructors Registry** - `src/main/controllers/registry.ts` exports `controllerIpcConstructors` and `DesktopIpcServices`, enabling automatic typing of renderer IPC proxies.
|
||||
- **Renderer Proxy Helper** - `src/utils/electron/ipc.ts` exposes `ensureElectronIpc()` which lazily builds a proxy on top of `window.electronAPI.invoke`, giving React/Next.js code a type-safe API surface without exposing raw proxies in preload.
|
||||
- **Server Proxy Helper** - `src/server/modules/ElectronIPCClient/index.ts` mirrors the same typing strategy for the Next.js server runtime, providing a dedicated proxy for `@IpcServerMethod` handlers.
|
||||
- **Shared Typings Package** - `apps/desktop/src/main/exports.d.ts` augments `@lobechat/electron-client-ipc` so every package can consume `DesktopIpcServices` without importing desktop business code directly.
|
||||
|
||||
#### 🪟 Window Management
|
||||
@@ -278,20 +277,6 @@ await ipc.windows.openSettingsWindow({ tab: 'provider' });
|
||||
|
||||
The helper internally builds a proxy on top of `window.electronAPI.invoke`, so no proxy objects need to be cloned across the preload boundary.
|
||||
|
||||
##### 🖥️ Server IPC Helper
|
||||
|
||||
Next.js (Node) modules use the same proxy pattern via `ensureElectronServerIpc` from `src/server/modules/ElectronIPCClient`. It lazily wraps the socket-based `ElectronIpcClient` so server code can call controllers with full type safety:
|
||||
|
||||
```ts
|
||||
import { ensureElectronServerIpc } from '@/server/modules/ElectronIPCClient';
|
||||
|
||||
const ipc = ensureElectronServerIpc();
|
||||
const dbPath = await ipc.system.getDatabasePath();
|
||||
await ipc.upload.deleteFiles(['foo.txt']);
|
||||
```
|
||||
|
||||
All server methods are declared via `@IpcServerMethod` and live in dedicated controller classes, keeping renderer typings clean.
|
||||
|
||||
#### 🛡️ Security Features
|
||||
|
||||
- **OAuth 2.0 + PKCE** - Secure authentication with state parameter validation
|
||||
|
||||
@@ -183,7 +183,7 @@ src/main/core/
|
||||
#### 🔌 依赖注入和事件系统
|
||||
|
||||
- **IoC 容器** - 基于 WeakMap 的装饰控制器方法容器
|
||||
- **装饰器注册** - `@IpcMethod` 和 `@IpcServerMethod` 装饰器
|
||||
- **装饰器注册** - `@IpcMethod` 装饰器
|
||||
- **自动事件映射** - 控制器加载期间注册的事件
|
||||
- **服务定位器** - 类型安全的服务和控制器检索
|
||||
|
||||
@@ -267,20 +267,6 @@ const ipc = ensureElectronIpc();
|
||||
await ipc.windows.openSettingsWindow({ tab: 'provider' });
|
||||
```
|
||||
|
||||
##### 🖥️ Server IPC 助手
|
||||
|
||||
Next.js 服务端模块可通过 `ensureElectronServerIpc`(位于 `src/server/modules/ElectronIPCClient`)获得同样的类型安全代理,并复用 socket IPC 通道:
|
||||
|
||||
```ts
|
||||
import { ensureElectronServerIpc } from '@/server/modules/ElectronIPCClient';
|
||||
|
||||
const ipc = ensureElectronServerIpc();
|
||||
const path = await ipc.system.getDatabasePath();
|
||||
await ipc.upload.deleteFiles(['foo.txt']);
|
||||
```
|
||||
|
||||
所有 `@IpcServerMethod` 方法都放在独立的控制器中,这样渲染端的类型推导不会包含这些仅供服务器调用的通道。
|
||||
|
||||
#### 🛡️ 安全功能
|
||||
|
||||
- **OAuth 2.0 + PKCE** - 具有状态参数验证的安全认证
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { CreateFileParams } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, IpcServerMethod } from './index';
|
||||
|
||||
export default class UploadFileServerCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getFileUrlById(id: string) {
|
||||
return this.fileService.getFilePath(id);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getFileHTTPURL(path: string) {
|
||||
return this.fileService.getFileHTTPURL(path);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async deleteFiles(paths: string[]) {
|
||||
return this.fileService.deleteFiles(paths);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async createFile(params: CreateFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import UploadFileServerCtr from '../UploadFileServerCtr';
|
||||
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
const mockFileService = {
|
||||
getFileHTTPURL: vi.fn(),
|
||||
getFilePath: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileServerCtr', () => {
|
||||
let controller: UploadFileServerCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new UploadFileServerCtr(mockApp);
|
||||
});
|
||||
|
||||
it('gets file path by id', async () => {
|
||||
mockFileService.getFilePath.mockResolvedValue('path');
|
||||
await expect(controller.getFileUrlById('id')).resolves.toBe('path');
|
||||
expect(mockFileService.getFilePath).toHaveBeenCalledWith('id');
|
||||
});
|
||||
|
||||
it('gets HTTP URL', async () => {
|
||||
mockFileService.getFileHTTPURL.mockResolvedValue('url');
|
||||
await expect(controller.getFileHTTPURL('/path')).resolves.toBe('url');
|
||||
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith('/path');
|
||||
});
|
||||
|
||||
it('deletes files', async () => {
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
await controller.deleteFiles(['a']);
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(['a']);
|
||||
});
|
||||
|
||||
it('creates files via upload service', async () => {
|
||||
const params = { filename: 'file' } as any;
|
||||
mockFileService.uploadFile.mockResolvedValue({ success: true });
|
||||
|
||||
await expect(controller.createFile(params)).resolves.toEqual({ success: true });
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
@@ -50,4 +50,4 @@ export class ControllerModule extends IpcService implements IControllerModule {
|
||||
|
||||
export type IControlModule = typeof ControllerModule;
|
||||
|
||||
export { IpcMethod, IpcServerMethod } from '@/utils/ipc';
|
||||
export { IpcMethod } from '@/utils/ipc';
|
||||
|
||||
@@ -17,7 +17,6 @@ import SystemController from './SystemCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
import UploadFileServerCtr from './UploadFileServerCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
AuthCtr,
|
||||
@@ -42,11 +41,3 @@ export const controllerIpcConstructors = [
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
type DesktopControllerServices = CreateServicesResult<DesktopControllerIpcConstructors>;
|
||||
export type DesktopIpcServices = MergeIpcService<DesktopControllerServices>;
|
||||
|
||||
export const controllerServerIpcConstructors = [
|
||||
UploadFileServerCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerServerConstructors = typeof controllerServerIpcConstructors;
|
||||
type DesktopServerControllerServices = CreateServicesResult<DesktopControllerServerConstructors>;
|
||||
export type DesktopServerIpcServices = MergeIpcService<DesktopServerControllerServices>;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { getServerMethodMetadata } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
@@ -330,15 +329,6 @@ export class App {
|
||||
const controller = new ControllerClass(this);
|
||||
this.controllers.set(ControllerClass, controller);
|
||||
|
||||
const serverMethods = getServerMethodMetadata(ControllerClass);
|
||||
serverMethods?.forEach((methodName, propertyKey) => {
|
||||
const channel = `${ControllerClass.groupName}.${methodName}`;
|
||||
this.ipcServerEventMap.set(channel, {
|
||||
controller,
|
||||
methodName: propertyKey,
|
||||
});
|
||||
});
|
||||
|
||||
IoCContainer.shortcuts.get(ControllerClass)?.forEach((shortcut) => {
|
||||
this.shortcutMethodMap.set(shortcut.name, async () => {
|
||||
controller[shortcut.methodName]();
|
||||
@@ -408,4 +398,4 @@ export class App {
|
||||
// 执行清理操作
|
||||
this.staticFileServerManager.destroy();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,32 +4,27 @@ import {
|
||||
BrowserWindowConstructorOptions,
|
||||
session as electronSession,
|
||||
ipcMain,
|
||||
nativeTheme,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import console from 'node:console';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isDev, isMac, isWindows } from '@/const/env';
|
||||
import { preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isMac } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import {
|
||||
BACKGROUND_DARK,
|
||||
BACKGROUND_LIGHT,
|
||||
SYMBOL_COLOR_DARK,
|
||||
SYMBOL_COLOR_LIGHT,
|
||||
THEME_CHANGE_DELAY,
|
||||
TITLE_BAR_HEIGHT,
|
||||
} from '@/const/theme';
|
||||
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
|
||||
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
|
||||
import { setResponseHeader } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from '../App';
|
||||
import { WindowStateManager } from './WindowStateManager';
|
||||
import { WindowThemeManager } from './WindowThemeManager';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:Browser');
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
devTools?: boolean;
|
||||
height?: number;
|
||||
@@ -42,348 +37,93 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface WindowState {
|
||||
height?: number;
|
||||
width?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
// ==================== Browser Class ====================
|
||||
|
||||
export default class Browser {
|
||||
private app: App;
|
||||
private _browserWindow?: BrowserWindow;
|
||||
private themeListenerSetup = false;
|
||||
identifier: string;
|
||||
options: BrowserWindowOpts;
|
||||
private readonly windowStateKey: string;
|
||||
private readonly app: App;
|
||||
private readonly stateManager: WindowStateManager;
|
||||
private readonly themeManager: WindowThemeManager;
|
||||
|
||||
get browserWindow() {
|
||||
private _browserWindow?: BrowserWindow;
|
||||
|
||||
readonly identifier: string;
|
||||
readonly options: BrowserWindowOpts;
|
||||
|
||||
// ==================== Accessors ====================
|
||||
|
||||
get browserWindow(): BrowserWindow {
|
||||
return this.retrieveOrInitialize();
|
||||
}
|
||||
|
||||
get webContents() {
|
||||
if (this._browserWindow.isDestroyed()) return null;
|
||||
return this._browserWindow.webContents;
|
||||
if (this._browserWindow?.isDestroyed()) return null;
|
||||
return this._browserWindow?.webContents ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to construct BrowserWindows object
|
||||
* @param options
|
||||
* @param application
|
||||
*/
|
||||
// ==================== Constructor ====================
|
||||
|
||||
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
|
||||
// Initialize managers
|
||||
this.stateManager = new WindowStateManager(application, {
|
||||
identifier: options.identifier,
|
||||
keepAlive: options.keepAlive,
|
||||
});
|
||||
this.themeManager = new WindowThemeManager(options.identifier);
|
||||
|
||||
// Initialize window
|
||||
this.retrieveOrInitialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific theme configuration for window creation
|
||||
*/
|
||||
private getPlatformThemeConfig(): Record<string, any> {
|
||||
if (isWindows) {
|
||||
return this.getWindowsThemeConfig(this.isDarkMode);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
// ==================== Window Lifecycle ====================
|
||||
|
||||
/**
|
||||
* Get Windows-specific theme configuration
|
||||
* Initialize or retrieve existing browser window
|
||||
*/
|
||||
private getWindowsThemeConfig(isDarkMode: boolean) {
|
||||
return {
|
||||
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
|
||||
titleBarOverlay: {
|
||||
color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
height: TITLE_BAR_HEIGHT,
|
||||
symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
|
||||
},
|
||||
titleBarStyle: 'hidden' as const,
|
||||
};
|
||||
}
|
||||
|
||||
private setupThemeListener(): void {
|
||||
if (this.themeListenerSetup) return;
|
||||
|
||||
nativeTheme.on('updated', this.handleThemeChange);
|
||||
this.themeListenerSetup = true;
|
||||
}
|
||||
|
||||
private handleThemeChange = (): void => {
|
||||
logger.debug(`[${this.identifier}] System theme changed, reapplying visual effects.`);
|
||||
setTimeout(() => {
|
||||
this.applyVisualEffects();
|
||||
}, THEME_CHANGE_DELAY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle application theme mode change (called from BrowserManager)
|
||||
*/
|
||||
handleAppThemeChange = (): void => {
|
||||
logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
|
||||
setTimeout(() => {
|
||||
this.applyVisualEffects();
|
||||
}, THEME_CHANGE_DELAY);
|
||||
};
|
||||
|
||||
private applyVisualEffects(): void {
|
||||
if (!this._browserWindow || this._browserWindow.isDestroyed()) return;
|
||||
|
||||
logger.debug(`[${this.identifier}] Applying visual effects for platform`);
|
||||
const isDarkMode = this.isDarkMode;
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
this.applyWindowsVisualEffects(isDarkMode);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private applyWindowsVisualEffects(isDarkMode: boolean): void {
|
||||
const config = this.getWindowsThemeConfig(isDarkMode);
|
||||
|
||||
this._browserWindow.setBackgroundColor(config.backgroundColor);
|
||||
this._browserWindow.setTitleBarOverlay(config.titleBarOverlay);
|
||||
}
|
||||
|
||||
private clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
private resolveWindowState(
|
||||
savedState: WindowState | undefined,
|
||||
fallbackState: { height?: number; width?: number },
|
||||
): WindowState {
|
||||
const width = savedState?.width ?? fallbackState.width;
|
||||
const height = savedState?.height ?? fallbackState.height;
|
||||
const resolvedState: WindowState = { height, width };
|
||||
|
||||
const hasPosition = Number.isFinite(savedState?.x) && Number.isFinite(savedState?.y);
|
||||
if (!hasPosition) return resolvedState;
|
||||
|
||||
const x = savedState?.x as number;
|
||||
const y = savedState?.y as number;
|
||||
|
||||
const targetDisplay = screen.getDisplayMatching({
|
||||
height: height ?? 0,
|
||||
width: width ?? 0,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
|
||||
const workArea = targetDisplay?.workArea ?? screen.getPrimaryDisplay().workArea;
|
||||
const resolvedWidth = typeof width === 'number' ? Math.min(width, workArea.width) : width;
|
||||
const resolvedHeight = typeof height === 'number' ? Math.min(height, workArea.height) : height;
|
||||
|
||||
const maxX = workArea.x + Math.max(0, workArea.width - (resolvedWidth ?? 0));
|
||||
const maxY = workArea.y + Math.max(0, workArea.height - (resolvedHeight ?? 0));
|
||||
|
||||
return {
|
||||
height: resolvedHeight,
|
||||
width: resolvedWidth,
|
||||
x: this.clampNumber(x, workArea.x, maxX),
|
||||
y: this.clampNumber(y, workArea.y, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
private cleanupThemeListener(): void {
|
||||
if (this.themeListenerSetup) {
|
||||
// Note: nativeTheme listeners are global, consider using a centralized theme manager
|
||||
nativeTheme.off('updated', this.handleThemeChange);
|
||||
// for multiple windows to avoid duplicate listeners
|
||||
this.themeListenerSetup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private get isDarkMode() {
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
}
|
||||
|
||||
loadUrl = async (path: string) => {
|
||||
const initUrl = await this.app.buildRendererUrl(path);
|
||||
|
||||
// Inject locale from store to help renderer boot with the correct language.
|
||||
// Skip when set to auto to let the renderer detect locale normally.
|
||||
const storedLocale = this.app.storeManager.get('locale', 'auto');
|
||||
const urlWithLocale =
|
||||
storedLocale && storedLocale !== 'auto'
|
||||
? `${initUrl}${initUrl.includes('?') ? '&' : '?'}lng=${storedLocale}`
|
||||
: initUrl;
|
||||
|
||||
console.log('[Browser] initUrl', urlWithLocale);
|
||||
|
||||
try {
|
||||
logger.debug(`[${this.identifier}] Attempting to load URL: ${urlWithLocale}`);
|
||||
await this._browserWindow.loadURL(urlWithLocale);
|
||||
|
||||
logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to load URL (${urlWithLocale}):`, 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: ${urlWithLocale}`);
|
||||
try {
|
||||
await this._browserWindow?.loadURL(urlWithLocale);
|
||||
logger.info(`[${this.identifier}] Reconnection successful to ${urlWithLocale}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.error(`[${this.identifier}] Retry connection failed for ${urlWithLocale}:`, 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}`);
|
||||
|
||||
// Fix for macOS fullscreen black screen issue
|
||||
// See: https://github.com/electron/electron/issues/20263
|
||||
if (isMac && this.browserWindow.isFullScreen()) {
|
||||
logger.debug(
|
||||
`[${this.identifier}] Window is in fullscreen mode, exiting fullscreen before hiding.`,
|
||||
);
|
||||
this.browserWindow.once('leave-full-screen', () => {
|
||||
this.browserWindow.hide();
|
||||
});
|
||||
this.browserWindow.setFullScreen(false);
|
||||
} else {
|
||||
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.cleanupThemeListener();
|
||||
this._browserWindow = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
retrieveOrInitialize() {
|
||||
// When there is this window and it has not been destroyed
|
||||
retrieveOrInitialize(): BrowserWindow {
|
||||
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;
|
||||
const browserWindow = this.createBrowserWindow();
|
||||
this._browserWindow = browserWindow;
|
||||
|
||||
// Load window state
|
||||
const savedState = this.app.storeManager.get(this.windowStateKey as any) as
|
||||
| WindowState
|
||||
| undefined;
|
||||
this.setupWindow(browserWindow);
|
||||
|
||||
logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`);
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy window instance and cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
logger.debug(`Destroying window instance: ${this.identifier}`);
|
||||
this.themeManager.cleanup();
|
||||
this._browserWindow = undefined;
|
||||
}
|
||||
|
||||
// ==================== Window Creation ====================
|
||||
|
||||
private createBrowserWindow(): BrowserWindow {
|
||||
const { title, width, height, ...rest } = this.options;
|
||||
|
||||
const resolvedState = this.stateManager.resolveState({ height, width });
|
||||
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: ${JSON.stringify(savedState)}`);
|
||||
|
||||
const resolvedState = this.resolveWindowState(savedState, { height, width });
|
||||
logger.debug(`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`);
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
...res,
|
||||
return new BrowserWindow({
|
||||
...rest,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#00000000',
|
||||
darkTheme: this.isDarkMode,
|
||||
darkTheme: this.themeManager.isDarkMode,
|
||||
frame: false,
|
||||
height: resolvedState.height,
|
||||
show: false,
|
||||
@@ -399,227 +139,309 @@ export default class Browser {
|
||||
width: resolvedState.width,
|
||||
x: resolvedState.x,
|
||||
y: resolvedState.y,
|
||||
...this.getPlatformThemeConfig(),
|
||||
...this.themeManager.getPlatformConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
this._browserWindow = browserWindow;
|
||||
private setupWindow(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
|
||||
|
||||
// Initialize theme listener for this window to handle theme changes
|
||||
this.setupThemeListener();
|
||||
logger.debug(`[${this.identifier}] Theme listener setup and applying initial visual effects.`);
|
||||
// Setup theme management
|
||||
this.themeManager.attach(browserWindow);
|
||||
|
||||
// Apply initial visual effects
|
||||
this.applyVisualEffects();
|
||||
|
||||
// Setup CORS bypass for local file server
|
||||
// Setup network interceptors
|
||||
this.setupCORSBypass(browserWindow);
|
||||
// Setup request hook for remote server sync (base URL rewrite + OIDC header)
|
||||
this.setupRemoteServerRequestHook(browserWindow);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
// Load content
|
||||
this.initiateContentLoading();
|
||||
|
||||
// Show devtools if enabled
|
||||
if (devTools) {
|
||||
logger.debug(`[${this.identifier}] Opening DevTools because devTools option is true.`);
|
||||
// Setup devtools if enabled
|
||||
if (this.options.devTools) {
|
||||
logger.debug(`[${this.identifier}] Opening DevTools.`);
|
||||
browserWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners(browserWindow);
|
||||
}
|
||||
|
||||
private initiateContentLoading(): void {
|
||||
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
|
||||
this.loadPlaceholder().then(() => {
|
||||
this.loadUrl(this.options.path).catch((e) => {
|
||||
logger.error(
|
||||
`[${this.identifier}] Initial loadUrl error for path '${this.options.path}':`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Event Listeners ====================
|
||||
|
||||
private setupEventListeners(browserWindow: BrowserWindow): void {
|
||||
this.setupReadyToShowListener(browserWindow);
|
||||
this.setupCloseListener(browserWindow);
|
||||
}
|
||||
|
||||
private setupReadyToShowListener(browserWindow: BrowserWindow): void {
|
||||
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) {
|
||||
if (this.options.showOnInit) {
|
||||
logger.debug(`Showing window ${this.identifier} because showOnInit is true.`);
|
||||
browserWindow?.show();
|
||||
browserWindow.show();
|
||||
} else {
|
||||
logger.debug(
|
||||
`Window ${this.identifier} not shown on 'ready-to-show' because showOnInit is false.`,
|
||||
);
|
||||
logger.debug(`Window ${this.identifier} not shown 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 bounds = browserWindow.getBounds();
|
||||
const sizeState = {
|
||||
height: bounds.height,
|
||||
width: bounds.width,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
};
|
||||
logger.debug(
|
||||
`[${this.identifier}] Saving window state on quit: ${JSON.stringify(sizeState)}`,
|
||||
);
|
||||
this.app.storeManager.set(this.windowStateKey as any, sizeState);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
|
||||
}
|
||||
// Need to clean up theme manager
|
||||
this.cleanupThemeListener();
|
||||
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();
|
||||
this.hide();
|
||||
} else {
|
||||
// Window is actually closing (not keepAlive)
|
||||
logger.debug(
|
||||
`[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
|
||||
);
|
||||
try {
|
||||
const bounds = browserWindow.getBounds();
|
||||
const sizeState = {
|
||||
height: bounds.height,
|
||||
width: bounds.width,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
};
|
||||
logger.debug(
|
||||
`[${this.identifier}] Saving window state on close: ${JSON.stringify(sizeState)}`,
|
||||
);
|
||||
this.app.storeManager.set(this.windowStateKey as any, sizeState);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
|
||||
}
|
||||
// Need to clean up theme manager
|
||||
this.cleanupThemeListener();
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`);
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
moveToCenter() {
|
||||
private setupCloseListener(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] Setting up 'close' event listener.`);
|
||||
const closeHandler = this.stateManager.createCloseHandler(browserWindow, {
|
||||
onCleanup: () => this.themeManager.cleanup(),
|
||||
onHide: () => this.hide(),
|
||||
});
|
||||
browserWindow.on('close', closeHandler);
|
||||
}
|
||||
|
||||
// ==================== Window Actions ====================
|
||||
|
||||
show(): void {
|
||||
logger.debug(`Showing window: ${this.identifier}`);
|
||||
if (!this._browserWindow?.isDestroyed()) {
|
||||
this.determineWindowPosition();
|
||||
}
|
||||
this.browserWindow.show();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
logger.debug(`Hiding window: ${this.identifier}`);
|
||||
|
||||
// Fix for macOS fullscreen black screen issue
|
||||
// See: https://github.com/electron/electron/issues/20263
|
||||
if (isMac && this.browserWindow.isFullScreen()) {
|
||||
logger.debug(`[${this.identifier}] Exiting fullscreen before hiding.`);
|
||||
this.browserWindow.once('leave-full-screen', () => {
|
||||
this.browserWindow.hide();
|
||||
});
|
||||
this.browserWindow.setFullScreen(false);
|
||||
} else {
|
||||
this.browserWindow.hide();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
logger.debug(`Attempting to close window: ${this.identifier}`);
|
||||
this.browserWindow.close();
|
||||
}
|
||||
|
||||
toggleVisible(): void {
|
||||
logger.debug(`Toggling visibility for window: ${this.identifier}`);
|
||||
if (this._browserWindow?.isVisible() && this._browserWindow.isFocused()) {
|
||||
this.hide();
|
||||
} else {
|
||||
this._browserWindow?.show();
|
||||
this._browserWindow?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
moveToCenter(): void {
|
||||
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();
|
||||
setWindowSize(boundSize: { height?: number; width?: number }): void {
|
||||
logger.debug(`Setting window size for ${this.identifier}: ${JSON.stringify(boundSize)}`);
|
||||
const currentBounds = this._browserWindow?.getBounds();
|
||||
this._browserWindow?.setBounds({
|
||||
height: boundSize.height || windowSize.height,
|
||||
width: boundSize.width || windowSize.width,
|
||||
height: boundSize.height || currentBounds?.height,
|
||||
width: boundSize.width || currentBounds?.width,
|
||||
});
|
||||
}
|
||||
|
||||
setWindowResizable(resizable: boolean) {
|
||||
setWindowResizable(resizable: boolean): void {
|
||||
logger.debug(`[${this.identifier}] Setting window resizable: ${resizable}`);
|
||||
this._browserWindow?.setResizable(resizable);
|
||||
}
|
||||
|
||||
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
|
||||
if (this._browserWindow.isDestroyed()) return;
|
||||
// ==================== Window Position ====================
|
||||
|
||||
logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`);
|
||||
this._browserWindow.webContents.send(channel, data);
|
||||
private determineWindowPosition(): void {
|
||||
const { parentIdentifier } = this.options;
|
||||
if (!parentIdentifier) return;
|
||||
|
||||
// todo: fix ts type
|
||||
const parentWin = this.app.browserManager.retrieveByIdentifier(parentIdentifier as any);
|
||||
if (!parentWin) return;
|
||||
|
||||
logger.debug(`[${this.identifier}] Found parent window: ${parentIdentifier}`);
|
||||
|
||||
const display = screen.getDisplayNearestPoint(parentWin.browserWindow.getContentBounds());
|
||||
if (!display) return;
|
||||
|
||||
const { workArea } = display;
|
||||
const { width, height } = this._browserWindow!.getContentBounds();
|
||||
|
||||
const newX = Math.floor(Math.max(workArea.x + (workArea.width - width) / 2, workArea.x));
|
||||
const newY = Math.floor(Math.max(workArea.y + (workArea.height - height) / 2, workArea.y));
|
||||
|
||||
logger.debug(`[${this.identifier}] Calculated position: x=${newX}, y=${newY}`);
|
||||
this._browserWindow!.setPosition(newX, newY, false);
|
||||
}
|
||||
|
||||
// ==================== Content Loading ====================
|
||||
|
||||
loadPlaceholder = async (): Promise<void> => {
|
||||
logger.debug(`[${this.identifier}] Loading splash screen placeholder`);
|
||||
await this._browserWindow!.loadFile(join(resourcesDir, 'splash.html'));
|
||||
logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`);
|
||||
};
|
||||
|
||||
toggleVisible() {
|
||||
logger.debug(`Toggling visibility for window: ${this.identifier}`);
|
||||
if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) {
|
||||
this.hide(); // Use the hide() method which handles fullscreen
|
||||
} else {
|
||||
this._browserWindow.show();
|
||||
this._browserWindow.focus();
|
||||
loadUrl = async (path: string): Promise<void> => {
|
||||
const initUrl = await this.app.buildRendererUrl(path);
|
||||
const urlWithLocale = this.buildUrlWithLocale(initUrl);
|
||||
|
||||
console.log('[Browser] initUrl', urlWithLocale);
|
||||
|
||||
try {
|
||||
logger.debug(`[${this.identifier}] Attempting to load URL: ${urlWithLocale}`);
|
||||
await this._browserWindow!.loadURL(urlWithLocale);
|
||||
logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to load URL (${urlWithLocale}):`, error);
|
||||
await this.handleLoadError(urlWithLocale);
|
||||
}
|
||||
};
|
||||
|
||||
private buildUrlWithLocale(initUrl: string): string {
|
||||
const storedLocale = this.app.storeManager.get('locale', 'auto');
|
||||
if (storedLocale && storedLocale !== 'auto') {
|
||||
return `${initUrl}${initUrl.includes('?') ? '&' : '?'}lng=${storedLocale}`;
|
||||
}
|
||||
return initUrl;
|
||||
}
|
||||
|
||||
private async handleLoadError(urlWithLocale: string): Promise<void> {
|
||||
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.`);
|
||||
|
||||
this.setupRetryHandler(urlWithLocale);
|
||||
} catch (err) {
|
||||
logger.error(`[${this.identifier}] Failed to load error page:`, err);
|
||||
await this.loadFallbackError();
|
||||
}
|
||||
}
|
||||
|
||||
private setupRetryHandler(urlWithLocale: string): void {
|
||||
ipcMain.removeHandler('retry-connection');
|
||||
logger.debug(`[${this.identifier}] Removed existing retry-connection handler if any.`);
|
||||
|
||||
ipcMain.handle('retry-connection', async () => {
|
||||
logger.info(`[${this.identifier}] Retry connection requested for: ${urlWithLocale}`);
|
||||
try {
|
||||
await this._browserWindow?.loadURL(urlWithLocale);
|
||||
logger.info(`[${this.identifier}] Reconnection successful to ${urlWithLocale}`);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error(`[${this.identifier}] Retry connection failed:`, err);
|
||||
try {
|
||||
await this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
|
||||
} 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.`);
|
||||
}
|
||||
|
||||
private async loadFallbackError(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Communication ====================
|
||||
|
||||
broadcast = <T extends MainBroadcastEventKey>(
|
||||
channel: T,
|
||||
data?: MainBroadcastParams<T>,
|
||||
): void => {
|
||||
if (this._browserWindow?.isDestroyed()) return;
|
||||
logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`);
|
||||
this._browserWindow!.webContents.send(channel, data);
|
||||
};
|
||||
|
||||
// ==================== Theme (Delegated) ====================
|
||||
|
||||
/**
|
||||
* Manually reapply visual effects (useful for fixing lost effects after window state changes)
|
||||
* Handle application theme mode change (called from BrowserManager)
|
||||
*/
|
||||
handleAppThemeChange = (): void => {
|
||||
this.themeManager.handleAppThemeChange();
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually reapply visual effects
|
||||
*/
|
||||
reapplyVisualEffects(): void {
|
||||
logger.debug(`[${this.identifier}] Manually reapplying visual effects via Browser.`);
|
||||
this.applyVisualEffects();
|
||||
this.themeManager.reapplyVisualEffects();
|
||||
}
|
||||
|
||||
// ==================== Network Setup ====================
|
||||
|
||||
/**
|
||||
* Setup CORS bypass for ALL requests
|
||||
* In production, the renderer uses app://next protocol which triggers CORS for all external requests
|
||||
* This completely bypasses CORS by:
|
||||
* 1. Removing Origin header from requests (prevents OPTIONS preflight)
|
||||
* 2. Adding proper CORS response headers using the stored origin value
|
||||
* In production, the renderer uses app://next protocol which triggers CORS
|
||||
*/
|
||||
private setupCORSBypass(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] Setting up CORS bypass for all requests`);
|
||||
|
||||
const session = browserWindow.webContents.session;
|
||||
|
||||
// Store origin values for each request ID
|
||||
const originMap = new Map<number, string>();
|
||||
|
||||
// Remove Origin header and store it for later use
|
||||
session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const requestHeaders = { ...details.requestHeaders };
|
||||
|
||||
// Store and remove Origin header to prevent CORS preflight
|
||||
if (requestHeaders['Origin']) {
|
||||
originMap.set(details.id, requestHeaders['Origin']);
|
||||
delete requestHeaders['Origin'];
|
||||
logger.debug(
|
||||
`[${this.identifier}] Removed Origin header for: ${details.url} (stored: ${requestHeaders['Origin']})`,
|
||||
);
|
||||
logger.debug(`[${this.identifier}] Removed Origin header for: ${details.url}`);
|
||||
}
|
||||
|
||||
callback({ requestHeaders });
|
||||
});
|
||||
|
||||
// Add CORS headers to ALL responses using stored origin
|
||||
session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const responseHeaders = details.responseHeaders || {};
|
||||
|
||||
// Get the original origin from our map, fallback to default
|
||||
const origin = originMap.get(details.id) || '*';
|
||||
|
||||
// Cannot use '*' when Access-Control-Allow-Credentials is true
|
||||
responseHeaders['Access-Control-Allow-Origin'] = [origin];
|
||||
responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, PUT, DELETE, OPTIONS, PATCH'];
|
||||
responseHeaders['Access-Control-Allow-Headers'] = ['*'];
|
||||
responseHeaders['Access-Control-Allow-Credentials'] = ['true'];
|
||||
// Force set CORS headers (replace existing to avoid duplicates from case-insensitive keys)
|
||||
setResponseHeader(responseHeaders, 'Access-Control-Allow-Origin', origin);
|
||||
setResponseHeader(
|
||||
responseHeaders,
|
||||
'Access-Control-Allow-Methods',
|
||||
'GET, POST, PUT, DELETE, OPTIONS, PATCH',
|
||||
);
|
||||
setResponseHeader(responseHeaders, 'Access-Control-Allow-Headers', '*');
|
||||
setResponseHeader(responseHeaders, 'Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Clean up the stored origin after response
|
||||
originMap.delete(details.id);
|
||||
|
||||
// For OPTIONS requests, add preflight cache and override status
|
||||
if (details.method === 'OPTIONS') {
|
||||
responseHeaders['Access-Control-Max-Age'] = ['86400']; // 24 hours
|
||||
logger.debug(`[${this.identifier}] Adding CORS headers to OPTIONS response`);
|
||||
|
||||
callback({
|
||||
responseHeaders,
|
||||
statusLine: 'HTTP/1.1 200 OK',
|
||||
});
|
||||
setResponseHeader(responseHeaders, 'Access-Control-Max-Age', '86400');
|
||||
callback({ responseHeaders, statusLine: 'HTTP/1.1 200 OK' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -630,10 +452,9 @@ export default class Browser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite tRPC requests to remote server and inject OIDC token via webRequest hooks.
|
||||
* Replaces the previous proxyTRPCRequest IPC forwarding.
|
||||
* Rewrite tRPC requests to remote server and inject OIDC token
|
||||
*/
|
||||
private setupRemoteServerRequestHook(browserWindow: BrowserWindow) {
|
||||
private setupRemoteServerRequestHook(browserWindow: BrowserWindow): void {
|
||||
const session = browserWindow.webContents.session;
|
||||
const remoteServerConfigCtr = this.app.getController(RemoteServerConfigCtr);
|
||||
|
||||
|
||||
180
apps/desktop/src/main/core/browser/WindowStateManager.ts
Normal file
180
apps/desktop/src/main/core/browser/WindowStateManager.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import Electron, { BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from '../App';
|
||||
|
||||
const logger = createLogger('core:WindowStateManager');
|
||||
|
||||
export interface WindowState {
|
||||
height?: number;
|
||||
width?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface WindowStateManagerOptions {
|
||||
identifier: string;
|
||||
keepAlive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages window state persistence and close behavior
|
||||
*/
|
||||
export class WindowStateManager {
|
||||
private readonly app: App;
|
||||
private readonly identifier: string;
|
||||
private readonly stateKey: string;
|
||||
private readonly keepAlive: boolean;
|
||||
|
||||
constructor(app: App, options: WindowStateManagerOptions) {
|
||||
this.app = app;
|
||||
this.identifier = options.identifier;
|
||||
this.stateKey = `windowSize_${options.identifier}`;
|
||||
this.keepAlive = options.keepAlive ?? false;
|
||||
}
|
||||
|
||||
// ==================== State Persistence ====================
|
||||
|
||||
/**
|
||||
* Load saved window state from persistent storage
|
||||
*/
|
||||
loadState(): WindowState | undefined {
|
||||
return this.app.storeManager.get(this.stateKey as any) as WindowState | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current window bounds to persistent storage
|
||||
*/
|
||||
saveState(browserWindow: BrowserWindow, context: 'quit' | 'close' | 'hide' = 'close'): void {
|
||||
try {
|
||||
const bounds = browserWindow.getBounds();
|
||||
const state: WindowState = {
|
||||
height: bounds.height,
|
||||
width: bounds.width,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
};
|
||||
logger.debug(
|
||||
`[${this.identifier}] Saving window state on ${context}: ${JSON.stringify(state)}`,
|
||||
);
|
||||
this.app.storeManager.set(this.stateKey as any, state);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on ${context}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== State Resolution ====================
|
||||
|
||||
/**
|
||||
* Resolve window state by merging saved state with fallback,
|
||||
* ensuring position is within visible screen bounds
|
||||
*/
|
||||
resolveState(fallback: { height?: number; width?: number }): WindowState {
|
||||
const savedState = this.loadState();
|
||||
return this.resolveWindowState(savedState, fallback);
|
||||
}
|
||||
|
||||
private resolveWindowState(
|
||||
savedState: WindowState | undefined,
|
||||
fallbackState: { height?: number; width?: number },
|
||||
): WindowState {
|
||||
const width = savedState?.width ?? fallbackState.width;
|
||||
const height = savedState?.height ?? fallbackState.height;
|
||||
const resolvedState: WindowState = { height, width };
|
||||
|
||||
const hasPosition = Number.isFinite(savedState?.x) && Number.isFinite(savedState?.y);
|
||||
if (!hasPosition) return resolvedState;
|
||||
|
||||
const x = savedState?.x as number;
|
||||
const y = savedState?.y as number;
|
||||
|
||||
const targetDisplay = screen.getDisplayMatching({
|
||||
height: height ?? 0,
|
||||
width: width ?? 0,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
|
||||
const workArea = targetDisplay?.workArea ?? screen.getPrimaryDisplay().workArea;
|
||||
const resolvedWidth = typeof width === 'number' ? Math.min(width, workArea.width) : width;
|
||||
const resolvedHeight = typeof height === 'number' ? Math.min(height, workArea.height) : height;
|
||||
|
||||
const maxX = workArea.x + Math.max(0, workArea.width - (resolvedWidth ?? 0));
|
||||
const maxY = workArea.y + Math.max(0, workArea.height - (resolvedHeight ?? 0));
|
||||
|
||||
return {
|
||||
height: resolvedHeight,
|
||||
width: resolvedWidth,
|
||||
x: this.clampNumber(x, workArea.x, maxX),
|
||||
y: this.clampNumber(y, workArea.y, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
private clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// ==================== Close Event Handling ====================
|
||||
|
||||
/**
|
||||
* Create a close event handler for the browser window
|
||||
* Returns a handler function and a cleanup function
|
||||
*/
|
||||
createCloseHandler(
|
||||
browserWindow: BrowserWindow,
|
||||
callbacks: {
|
||||
onCleanup: () => void;
|
||||
onHide: () => void;
|
||||
},
|
||||
): (e: Electron.Event) => void {
|
||||
return (e: Electron.Event) => {
|
||||
logger.debug(`Window 'close' event triggered for: ${this.identifier}`);
|
||||
logger.debug(
|
||||
`[${this.identifier}] State during close event: isQuiting=${this.app.isQuiting}, keepAlive=${this.keepAlive}`,
|
||||
);
|
||||
|
||||
if (this.app.isQuiting) {
|
||||
this.handleCloseOnQuit(browserWindow, callbacks.onCleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keepAlive) {
|
||||
this.handleCloseWithKeepAlive(e, callbacks.onHide);
|
||||
} else {
|
||||
this.handleCloseNormally(browserWindow, callbacks.onCleanup);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle close when application is quitting - save state and cleanup
|
||||
*/
|
||||
private handleCloseOnQuit(browserWindow: BrowserWindow, onCleanup: () => void): void {
|
||||
logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`);
|
||||
this.saveState(browserWindow, 'quit');
|
||||
onCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle close when keepAlive is enabled - prevent close and hide instead
|
||||
*/
|
||||
private handleCloseWithKeepAlive(e: Electron.Event, onHide: () => void): void {
|
||||
logger.debug(
|
||||
`[${this.identifier}] keepAlive is true, preventing default close and hiding window.`,
|
||||
);
|
||||
e.preventDefault();
|
||||
onHide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle normal close - save state, cleanup, and allow window to close
|
||||
*/
|
||||
private handleCloseNormally(browserWindow: BrowserWindow, onCleanup: () => void): void {
|
||||
logger.debug(
|
||||
`[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
|
||||
);
|
||||
this.saveState(browserWindow, 'close');
|
||||
onCleanup();
|
||||
}
|
||||
}
|
||||
167
apps/desktop/src/main/core/browser/WindowThemeManager.ts
Normal file
167
apps/desktop/src/main/core/browser/WindowThemeManager.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { BrowserWindow, nativeTheme } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildDir } from '@/const/dir';
|
||||
import { isDev, isWindows } from '@/const/env';
|
||||
import {
|
||||
BACKGROUND_DARK,
|
||||
BACKGROUND_LIGHT,
|
||||
SYMBOL_COLOR_DARK,
|
||||
SYMBOL_COLOR_LIGHT,
|
||||
THEME_CHANGE_DELAY,
|
||||
TITLE_BAR_HEIGHT,
|
||||
} from '@/const/theme';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const logger = createLogger('core:WindowThemeManager');
|
||||
|
||||
interface WindowsThemeConfig {
|
||||
backgroundColor: string;
|
||||
icon?: string;
|
||||
titleBarOverlay: {
|
||||
color: string;
|
||||
height: number;
|
||||
symbolColor: string;
|
||||
};
|
||||
titleBarStyle: 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages window theme configuration and visual effects
|
||||
*/
|
||||
export class WindowThemeManager {
|
||||
private readonly identifier: string;
|
||||
private browserWindow?: BrowserWindow;
|
||||
private listenerSetup = false;
|
||||
private boundHandleThemeChange: () => void;
|
||||
|
||||
constructor(identifier: string) {
|
||||
this.identifier = identifier;
|
||||
this.boundHandleThemeChange = this.handleThemeChange.bind(this);
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
/**
|
||||
* Attach to a browser window and setup theme handling
|
||||
*/
|
||||
attach(browserWindow: BrowserWindow): void {
|
||||
this.browserWindow = browserWindow;
|
||||
this.setupThemeListener();
|
||||
this.applyVisualEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup theme listener when window is destroyed
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.listenerSetup) {
|
||||
nativeTheme.off('updated', this.boundHandleThemeChange);
|
||||
this.listenerSetup = false;
|
||||
logger.debug(`[${this.identifier}] Theme listener cleaned up.`);
|
||||
}
|
||||
this.browserWindow = undefined;
|
||||
}
|
||||
|
||||
// ==================== Theme Configuration ====================
|
||||
|
||||
/**
|
||||
* Get current dark mode state
|
||||
*/
|
||||
get isDarkMode(): boolean {
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific theme configuration for window creation
|
||||
*/
|
||||
getPlatformConfig(): Partial<WindowsThemeConfig> {
|
||||
if (isWindows) {
|
||||
return this.getWindowsConfig(this.isDarkMode);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Windows-specific theme configuration
|
||||
*/
|
||||
private getWindowsConfig(isDarkMode: boolean): WindowsThemeConfig {
|
||||
return {
|
||||
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
|
||||
titleBarOverlay: {
|
||||
color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
height: TITLE_BAR_HEIGHT,
|
||||
symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Theme Listener ====================
|
||||
|
||||
private setupThemeListener(): void {
|
||||
if (this.listenerSetup) return;
|
||||
|
||||
nativeTheme.on('updated', this.boundHandleThemeChange);
|
||||
this.listenerSetup = true;
|
||||
logger.debug(`[${this.identifier}] Theme listener setup.`);
|
||||
}
|
||||
|
||||
private handleThemeChange(): void {
|
||||
logger.debug(`[${this.identifier}] System theme changed, reapplying visual effects.`);
|
||||
setTimeout(() => {
|
||||
this.applyVisualEffects();
|
||||
}, THEME_CHANGE_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle application theme mode change (called from BrowserManager)
|
||||
*/
|
||||
handleAppThemeChange(): void {
|
||||
logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
|
||||
setTimeout(() => {
|
||||
this.applyVisualEffects();
|
||||
}, THEME_CHANGE_DELAY);
|
||||
}
|
||||
|
||||
// ==================== Visual Effects ====================
|
||||
|
||||
/**
|
||||
* Apply visual effects based on current theme
|
||||
*/
|
||||
applyVisualEffects(): void {
|
||||
if (!this.browserWindow || this.browserWindow.isDestroyed()) return;
|
||||
|
||||
logger.debug(`[${this.identifier}] Applying visual effects for platform`);
|
||||
const isDarkMode = this.isDarkMode;
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
this.applyWindowsVisualEffects(isDarkMode);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reapply visual effects
|
||||
*/
|
||||
reapplyVisualEffects(): void {
|
||||
logger.debug(`[${this.identifier}] Manually reapplying visual effects.`);
|
||||
this.applyVisualEffects();
|
||||
}
|
||||
|
||||
private applyWindowsVisualEffects(isDarkMode: boolean): void {
|
||||
if (!this.browserWindow) return;
|
||||
|
||||
const config = this.getWindowsConfig(isDarkMode);
|
||||
this.browserWindow.setBackgroundColor(config.backgroundColor);
|
||||
this.browserWindow.setTitleBarOverlay(config.titleBarOverlay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { WindowStateManager } from '../WindowStateManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockScreen } = vi.hoisted(() => ({
|
||||
mockScreen: {
|
||||
getDisplayMatching: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
getPrimaryDisplay: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
screen: mockScreen,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('WindowStateManager', () => {
|
||||
let manager: WindowStateManager;
|
||||
let mockApp: AppCore;
|
||||
let mockStoreManagerGet: ReturnType<typeof vi.fn>;
|
||||
let mockStoreManagerSet: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
|
||||
mockStoreManagerSet = vi.fn();
|
||||
|
||||
mockApp = {
|
||||
isQuiting: false,
|
||||
storeManager: {
|
||||
get: mockStoreManagerGet,
|
||||
set: mockStoreManagerSet,
|
||||
},
|
||||
} as unknown as AppCore;
|
||||
|
||||
manager = new WindowStateManager(mockApp, {
|
||||
identifier: 'test-window',
|
||||
keepAlive: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadState', () => {
|
||||
it('should load state from store', () => {
|
||||
const savedState = { height: 700, width: 900, x: 100, y: 100 };
|
||||
mockStoreManagerGet.mockReturnValue(savedState);
|
||||
|
||||
const state = manager.loadState();
|
||||
|
||||
expect(mockStoreManagerGet).toHaveBeenCalledWith('windowSize_test-window');
|
||||
expect(state).toEqual(savedState);
|
||||
});
|
||||
|
||||
it('should return undefined when no saved state', () => {
|
||||
mockStoreManagerGet.mockReturnValue(undefined);
|
||||
|
||||
const state = manager.loadState();
|
||||
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveState', () => {
|
||||
it('should save window bounds to store', () => {
|
||||
const mockBrowserWindow = {
|
||||
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 50, y: 50 }),
|
||||
} as any;
|
||||
|
||||
manager.saveState(mockBrowserWindow, 'close');
|
||||
|
||||
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
||||
height: 600,
|
||||
width: 800,
|
||||
x: 50,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
const mockBrowserWindow = {
|
||||
getBounds: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Window destroyed');
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Should not throw
|
||||
expect(() => manager.saveState(mockBrowserWindow, 'close')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveState', () => {
|
||||
it('should use fallback when no saved state', () => {
|
||||
mockStoreManagerGet.mockReturnValue(undefined);
|
||||
|
||||
const state = manager.resolveState({ height: 600, width: 800 });
|
||||
|
||||
expect(state).toEqual({ height: 600, width: 800 });
|
||||
});
|
||||
|
||||
it('should use saved size over fallback', () => {
|
||||
mockStoreManagerGet.mockReturnValue({ height: 700, width: 900 });
|
||||
|
||||
const state = manager.resolveState({ height: 600, width: 800 });
|
||||
|
||||
expect(state).toEqual({ height: 700, width: 900 });
|
||||
});
|
||||
|
||||
it('should restore saved position when valid', () => {
|
||||
mockStoreManagerGet.mockReturnValue({ height: 700, width: 900, x: 100, y: 100 });
|
||||
|
||||
const state = manager.resolveState({ height: 600, width: 800 });
|
||||
|
||||
expect(state).toEqual({ height: 700, width: 900, x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('should clamp position to screen bounds', () => {
|
||||
mockStoreManagerGet.mockReturnValue({ height: 700, width: 900, x: 2000, y: 1500 });
|
||||
|
||||
const state = manager.resolveState({ height: 600, width: 800 });
|
||||
|
||||
// x should be clamped: maxX = 0 + max(0, 1920 - 900) = 1020
|
||||
// y should be clamped: maxY = 0 + max(0, 1080 - 700) = 380
|
||||
expect(state.x).toBe(1020);
|
||||
expect(state.y).toBe(380);
|
||||
});
|
||||
|
||||
it('should clamp size to screen bounds', () => {
|
||||
mockScreen.getDisplayMatching.mockReturnValueOnce({
|
||||
workArea: { height: 800, width: 1200, x: 0, y: 0 },
|
||||
});
|
||||
mockStoreManagerGet.mockReturnValue({ height: 1200, width: 2000, x: 0, y: 0 });
|
||||
|
||||
const state = manager.resolveState({ height: 600, width: 800 });
|
||||
|
||||
expect(state.width).toBe(1200);
|
||||
expect(state.height).toBe(800);
|
||||
});
|
||||
|
||||
it('should use primary display when no matching display found', () => {
|
||||
mockScreen.getDisplayMatching.mockReturnValueOnce(null);
|
||||
mockStoreManagerGet.mockReturnValue({ height: 700, width: 900, x: 100, y: 100 });
|
||||
|
||||
const state = manager.resolveState({ height: 600, width: 800 });
|
||||
|
||||
expect(mockScreen.getPrimaryDisplay).toHaveBeenCalled();
|
||||
expect(state).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCloseHandler', () => {
|
||||
let mockBrowserWindow: any;
|
||||
let onCleanup: ReturnType<typeof vi.fn>;
|
||||
let onHide: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockBrowserWindow = {
|
||||
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
|
||||
};
|
||||
onCleanup = vi.fn();
|
||||
onHide = vi.fn();
|
||||
});
|
||||
|
||||
describe('when app is quitting', () => {
|
||||
beforeEach(() => {
|
||||
(mockApp as any).isQuiting = true;
|
||||
});
|
||||
|
||||
it('should save state and call cleanup', () => {
|
||||
const handler = manager.createCloseHandler(mockBrowserWindow, { onCleanup, onHide });
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
handler(mockEvent as any);
|
||||
|
||||
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
||||
height: 600,
|
||||
width: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
expect(onCleanup).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when keepAlive is true', () => {
|
||||
beforeEach(() => {
|
||||
manager = new WindowStateManager(mockApp, {
|
||||
identifier: 'test-window',
|
||||
keepAlive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent close and call hide', () => {
|
||||
const handler = manager.createCloseHandler(mockBrowserWindow, { onCleanup, onHide });
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
handler(mockEvent as any);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(onHide).toHaveBeenCalled();
|
||||
expect(mockStoreManagerSet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when keepAlive is false (normal close)', () => {
|
||||
it('should save state and call cleanup', () => {
|
||||
const handler = manager.createCloseHandler(mockBrowserWindow, { onCleanup, onHide });
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
handler(mockEvent as any);
|
||||
|
||||
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
||||
height: 600,
|
||||
width: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
expect(onCleanup).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { WindowThemeManager } from '../WindowThemeManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockNativeTheme, mockBrowserWindow } = vi.hoisted(() => ({
|
||||
mockBrowserWindow: {
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setTitleBarOverlay: vi.fn(),
|
||||
},
|
||||
mockNativeTheme: {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
nativeTheme: mockNativeTheme,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
isWindows: true,
|
||||
}));
|
||||
|
||||
vi.mock('@/const/theme', () => ({
|
||||
BACKGROUND_DARK: '#1a1a1a',
|
||||
BACKGROUND_LIGHT: '#ffffff',
|
||||
SYMBOL_COLOR_DARK: '#ffffff',
|
||||
SYMBOL_COLOR_LIGHT: '#000000',
|
||||
THEME_CHANGE_DELAY: 0,
|
||||
TITLE_BAR_HEIGHT: 32,
|
||||
}));
|
||||
|
||||
describe('WindowThemeManager', () => {
|
||||
let manager: WindowThemeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
manager = new WindowThemeManager('test-window');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('isDarkMode', () => {
|
||||
it('should return true when shouldUseDarkColors is true', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = true;
|
||||
|
||||
expect(manager.isDarkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when shouldUseDarkColors is false', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
|
||||
expect(manager.isDarkMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatformConfig', () => {
|
||||
it('should return Windows dark theme config when in dark mode', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = true;
|
||||
|
||||
const config = manager.getPlatformConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
backgroundColor: '#1a1a1a',
|
||||
icon: undefined,
|
||||
titleBarOverlay: {
|
||||
color: '#1a1a1a',
|
||||
height: 32,
|
||||
symbolColor: '#ffffff',
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Windows light theme config when in light mode', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
|
||||
const config = manager.getPlatformConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
backgroundColor: '#ffffff',
|
||||
icon: undefined,
|
||||
titleBarOverlay: {
|
||||
color: '#ffffff',
|
||||
height: 32,
|
||||
symbolColor: '#000000',
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach', () => {
|
||||
it('should setup theme listener', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
|
||||
expect(mockNativeTheme.on).toHaveBeenCalledWith('updated', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should apply initial visual effects', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not setup duplicate listeners', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
|
||||
expect(mockNativeTheme.on).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove theme listener', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
manager.cleanup();
|
||||
|
||||
expect(mockNativeTheme.off).toHaveBeenCalledWith('updated', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should not throw if cleanup called without attach', () => {
|
||||
expect(() => manager.cleanup()).not.toThrow();
|
||||
expect(mockNativeTheme.off).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAppThemeChange', () => {
|
||||
it('should reapply visual effects after delay', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
mockBrowserWindow.setBackgroundColor.mockClear();
|
||||
mockBrowserWindow.setTitleBarOverlay.mockClear();
|
||||
|
||||
manager.handleAppThemeChange();
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reapplyVisualEffects', () => {
|
||||
it('should apply visual effects', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
mockBrowserWindow.setBackgroundColor.mockClear();
|
||||
|
||||
manager.reapplyVisualEffects();
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyVisualEffects', () => {
|
||||
it('should apply dark theme when in dark mode', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = true;
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#1a1a1a');
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalledWith({
|
||||
color: '#1a1a1a',
|
||||
height: 32,
|
||||
symbolColor: '#ffffff',
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply light theme when in light mode', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalledWith({
|
||||
color: '#ffffff',
|
||||
height: 32,
|
||||
symbolColor: '#000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply effects when window is destroyed', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
mockBrowserWindow.setBackgroundColor.mockClear();
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(true);
|
||||
|
||||
manager.reapplyVisualEffects();
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not apply effects when no window attached', () => {
|
||||
// Manager without attached window
|
||||
const freshManager = new WindowThemeManager('fresh-window');
|
||||
|
||||
// Should not throw
|
||||
expect(() => freshManager.reapplyVisualEffects()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme change listener', () => {
|
||||
it('should reapply visual effects on system theme change', () => {
|
||||
manager.attach(mockBrowserWindow as any);
|
||||
|
||||
// Get the theme change handler
|
||||
const themeHandler = mockNativeTheme.on.mock.calls.find((call) => call[0] === 'updated')?.[1];
|
||||
|
||||
expect(themeHandler).toBeDefined();
|
||||
|
||||
mockBrowserWindow.setBackgroundColor.mockClear();
|
||||
|
||||
// Simulate theme change
|
||||
themeHandler();
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
2
apps/desktop/src/main/exports.d.ts
vendored
2
apps/desktop/src/main/exports.d.ts
vendored
@@ -5,4 +5,4 @@ declare module '@lobechat/electron-client-ipc' {
|
||||
interface DesktopIpcServicesMap extends DesktopIpcServices {}
|
||||
}
|
||||
|
||||
export { type DesktopIpcServices, type DesktopServerIpcServices } from './controllers/registry';
|
||||
export { type DesktopIpcServices } from './controllers/registry';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Export types for renderer/server to use
|
||||
export type { DesktopIpcServices, DesktopServerIpcServices } from './controllers/registry';
|
||||
export type { DesktopIpcServices } from './controllers/registry';
|
||||
|
||||
131
apps/desktop/src/main/utils/__tests__/http-headers.test.ts
Normal file
131
apps/desktop/src/main/utils/__tests__/http-headers.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
deleteResponseHeader,
|
||||
getResponseHeader,
|
||||
hasResponseHeader,
|
||||
setResponseHeader,
|
||||
} from '../http-headers';
|
||||
|
||||
describe('http-headers utilities', () => {
|
||||
describe('setResponseHeader', () => {
|
||||
it('should set a new header', () => {
|
||||
const headers: Record<string, string[]> = {};
|
||||
|
||||
setResponseHeader(headers, 'Content-Type', 'application/json');
|
||||
|
||||
expect(headers['Content-Type']).toEqual(['application/json']);
|
||||
});
|
||||
|
||||
it('should replace existing header with same case', () => {
|
||||
const headers: Record<string, string[]> = {
|
||||
'Content-Type': ['text/html'],
|
||||
};
|
||||
|
||||
setResponseHeader(headers, 'Content-Type', 'application/json');
|
||||
|
||||
expect(headers['Content-Type']).toEqual(['application/json']);
|
||||
expect(Object.keys(headers)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should replace existing header with different case', () => {
|
||||
const headers: Record<string, string[]> = {
|
||||
'content-type': ['text/html'],
|
||||
};
|
||||
|
||||
setResponseHeader(headers, 'Content-Type', 'application/json');
|
||||
|
||||
expect(headers['Content-Type']).toEqual(['application/json']);
|
||||
expect(headers['content-type']).toBeUndefined();
|
||||
expect(Object.keys(headers)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle array values', () => {
|
||||
const headers: Record<string, string[]> = {};
|
||||
|
||||
setResponseHeader(headers, 'Set-Cookie', ['a=1', 'b=2']);
|
||||
|
||||
expect(headers['Set-Cookie']).toEqual(['a=1', 'b=2']);
|
||||
});
|
||||
|
||||
it('should replace multiple headers with different cases', () => {
|
||||
const headers: Record<string, string[]> = {
|
||||
'ACCESS-CONTROL-ALLOW-ORIGIN': ['*'],
|
||||
'access-control-allow-origin': ['http://localhost'],
|
||||
};
|
||||
|
||||
setResponseHeader(headers, 'Access-Control-Allow-Origin', 'http://example.com');
|
||||
|
||||
expect(headers['Access-Control-Allow-Origin']).toEqual(['http://example.com']);
|
||||
expect(Object.keys(headers)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasResponseHeader', () => {
|
||||
it('should return true for existing header', () => {
|
||||
const headers = { 'Content-Type': ['application/json'] };
|
||||
|
||||
expect(hasResponseHeader(headers, 'Content-Type')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for existing header with different case', () => {
|
||||
const headers = { 'content-type': ['application/json'] };
|
||||
|
||||
expect(hasResponseHeader(headers, 'Content-Type')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existing header', () => {
|
||||
const headers = { 'Content-Type': ['application/json'] };
|
||||
|
||||
expect(hasResponseHeader(headers, 'Authorization')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseHeader', () => {
|
||||
it('should get header value', () => {
|
||||
const headers = { 'Content-Type': ['application/json'] };
|
||||
|
||||
expect(getResponseHeader(headers, 'Content-Type')).toEqual(['application/json']);
|
||||
});
|
||||
|
||||
it('should get header value with different case', () => {
|
||||
const headers = { 'content-type': ['application/json'] };
|
||||
|
||||
expect(getResponseHeader(headers, 'Content-Type')).toEqual(['application/json']);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existing header', () => {
|
||||
const headers = { 'Content-Type': ['application/json'] };
|
||||
|
||||
expect(getResponseHeader(headers, 'Authorization')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteResponseHeader', () => {
|
||||
it('should delete existing header', () => {
|
||||
const headers: Record<string, string[]> = { 'Content-Type': ['application/json'] };
|
||||
|
||||
const result = deleteResponseHeader(headers, 'Content-Type');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delete header with different case', () => {
|
||||
const headers: Record<string, string[]> = { 'content-type': ['application/json'] };
|
||||
|
||||
const result = deleteResponseHeader(headers, 'Content-Type');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(headers['content-type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for non-existing header', () => {
|
||||
const headers: Record<string, string[]> = { 'Content-Type': ['application/json'] };
|
||||
|
||||
const result = deleteResponseHeader(headers, 'Authorization');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
apps/desktop/src/main/utils/http-headers.ts
Normal file
61
apps/desktop/src/main/utils/http-headers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* HTTP headers utilities for Electron webRequest
|
||||
*
|
||||
* Electron's webRequest responseHeaders is a plain JS object where keys are case-sensitive,
|
||||
* but HTTP headers are case-insensitive per spec. These utilities handle this mismatch.
|
||||
*/
|
||||
|
||||
type ElectronResponseHeaders = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* Set a header value, replacing any existing header with the same name (case-insensitive)
|
||||
*/
|
||||
export function setResponseHeader(
|
||||
headers: ElectronResponseHeaders,
|
||||
name: string,
|
||||
value: string | string[],
|
||||
): void {
|
||||
// Delete any existing header with same name (case-insensitive)
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === name.toLowerCase()) {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
headers[name] = Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a header exists (case-insensitive)
|
||||
*/
|
||||
export function hasResponseHeader(headers: ElectronResponseHeaders, name: string): boolean {
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a header value (case-insensitive)
|
||||
*/
|
||||
export function getResponseHeader(
|
||||
headers: ElectronResponseHeaders,
|
||||
name: string,
|
||||
): string[] | undefined {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === name.toLowerCase()) {
|
||||
return headers[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a header (case-insensitive)
|
||||
*/
|
||||
export function deleteResponseHeader(headers: ElectronResponseHeaders, name: string): boolean {
|
||||
let deleted = false;
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === name.toLowerCase()) {
|
||||
delete headers[key];
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { IpcContext } from '../base';
|
||||
import {
|
||||
IpcMethod,
|
||||
IpcServerMethod,
|
||||
IpcService,
|
||||
getIpcContext,
|
||||
getServerMethodMetadata,
|
||||
} from '../base';
|
||||
import { IpcMethod, IpcService, getIpcContext } from '../base';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
@@ -73,19 +67,4 @@ describe('ipc service base', () => {
|
||||
expect(service.invokedWith).toBe('test');
|
||||
expect(ipcMainHandleMock).toHaveBeenCalledWith('direct.run', expect.any(Function));
|
||||
});
|
||||
|
||||
it('collects server method metadata for decorators', () => {
|
||||
class ServerService extends IpcService {
|
||||
static readonly groupName = 'server';
|
||||
|
||||
@IpcServerMethod()
|
||||
fetch(_: string) {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = getServerMethodMetadata(ServerService);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata?.get('fetch')).toBe('fetch');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface IpcContext {
|
||||
|
||||
// Metadata storage for decorated methods
|
||||
const methodMetadata = new WeakMap<any, Map<string, string>>();
|
||||
const serverMethodMetadata = new WeakMap<any, Map<string, string>>();
|
||||
const ipcContextStorage = new AsyncLocalStorage<IpcContext>();
|
||||
|
||||
// Decorator for IPC methods
|
||||
@@ -29,21 +28,6 @@ export function IpcMethod() {
|
||||
};
|
||||
}
|
||||
|
||||
export function IpcServerMethod(channelName?: string) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const { constructor } = target;
|
||||
|
||||
if (!serverMethodMetadata.has(constructor)) {
|
||||
serverMethodMetadata.set(constructor, new Map());
|
||||
}
|
||||
|
||||
const methods = serverMethodMetadata.get(constructor)!;
|
||||
methods.set(propertyKey, channelName || propertyKey);
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
// Handler registry for IPC methods
|
||||
export class IpcHandler {
|
||||
private static instance: IpcHandler;
|
||||
@@ -157,10 +141,6 @@ export type CreateServicesResult<T extends readonly IpcServiceConstructor[]> = {
|
||||
[K in T[number] as K['groupName']]: InstanceType<K>;
|
||||
};
|
||||
|
||||
export function getServerMethodMetadata(target: IpcServiceConstructor) {
|
||||
return serverMethodMetadata.get(target);
|
||||
}
|
||||
|
||||
export function getIpcContext() {
|
||||
return ipcContextStorage.getStore();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
export type { CreateServicesResult, IpcContext, IpcServiceConstructor } from './base';
|
||||
export {
|
||||
createServices,
|
||||
getIpcContext,
|
||||
getServerMethodMetadata,
|
||||
IpcMethod,
|
||||
IpcServerMethod,
|
||||
IpcService,
|
||||
runWithIpcContext,
|
||||
} from './base';
|
||||
export { createServices, getIpcContext, IpcMethod, IpcService, runWithIpcContext } from './base';
|
||||
export type { ExtractServiceMethods, MergeIpcService } from './utility';
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { type CreateFileParams, ElectronIpcClient, type FileMetadata } from '@lobechat/electron-server-ipc';
|
||||
import type { DesktopServerIpcServices } from '@lobehub/desktop-ipc-typings';
|
||||
|
||||
import packageJSON from '@/../apps/desktop/package.json';
|
||||
|
||||
const createServerInvokeProxy = <IpcServices>(
|
||||
invoke: (channel: string, payload?: unknown) => Promise<unknown>,
|
||||
): IpcServices =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, groupKey) {
|
||||
if (typeof groupKey !== 'string') return undefined;
|
||||
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_methodTarget, methodKey) {
|
||||
if (typeof methodKey !== 'string') return undefined;
|
||||
|
||||
const channel = `${groupKey}.${methodKey}`;
|
||||
return (payload?: unknown) =>
|
||||
payload === undefined ? invoke(channel) : invoke(channel, payload);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
) as IpcServices;
|
||||
|
||||
class LobeHubElectronIpcClient extends ElectronIpcClient {
|
||||
private _services: DesktopServerIpcServices | null = null;
|
||||
|
||||
private ensureServices(): DesktopServerIpcServices {
|
||||
if (this._services) return this._services;
|
||||
|
||||
this._services = createServerInvokeProxy<DesktopServerIpcServices>((channel, payload) =>
|
||||
payload === undefined ? this.sendRequest(channel) : this.sendRequest(channel, payload),
|
||||
);
|
||||
|
||||
return this.services;
|
||||
}
|
||||
|
||||
private get ipc() {
|
||||
return this.ensureServices();
|
||||
}
|
||||
|
||||
public get services(): DesktopServerIpcServices {
|
||||
return this.ipc;
|
||||
}
|
||||
|
||||
getDatabasePath = async (): Promise<string> => {
|
||||
return this.ipc.system.getDatabasePath();
|
||||
};
|
||||
|
||||
getUserDataPath = async (): Promise<string> => {
|
||||
return this.ipc.system.getUserDataPath();
|
||||
};
|
||||
|
||||
getDatabaseSchemaHash = async () => {
|
||||
return this.ipc.system.getDatabaseSchemaHash();
|
||||
};
|
||||
|
||||
setDatabaseSchemaHash = async (hash: string | undefined) => {
|
||||
if (!hash) return;
|
||||
|
||||
return this.ipc.system.setDatabaseSchemaHash(hash);
|
||||
};
|
||||
|
||||
getFilePathById = async (id: string) => {
|
||||
return this.ipc.upload.getFileUrlById(id);
|
||||
};
|
||||
|
||||
getFileHTTPURL = async (path: string) => {
|
||||
return this.ipc.upload.getFileHTTPURL(path);
|
||||
};
|
||||
|
||||
deleteFiles = async (paths: string[]) => {
|
||||
return this.ipc.upload.deleteFiles(paths);
|
||||
};
|
||||
|
||||
createFile = async (params: CreateFileParams) => {
|
||||
return this.ipc.upload.createFile(params) as Promise<{
|
||||
metadata: FileMetadata;
|
||||
success: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const electronIpcClient = new LobeHubElectronIpcClient(packageJSON.name);
|
||||
|
||||
export const ensureElectronServerIpc = (): DesktopServerIpcServices => electronIpcClient.services;
|
||||
Reference in New Issue
Block a user