mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-16 04:25:59 +00:00
♻️ refactor(electron-main): client ipc decorate (#10679)
* refactor: client ipc * refactor: server ipc refactor: update IPC method names for consistency Signed-off-by: Innei <tukon479@gmail.com> fix: cast IPC return type to DesktopIpcServices for type safety Signed-off-by: Innei <tukon479@gmail.com> chore: add new workspace for desktop application in package.json Signed-off-by: Innei <tukon479@gmail.com> fix: export FileMetadata interface for improved accessibility Signed-off-by: Innei <tukon479@gmail.com> refactor: unify IPC mocking across test files for consistency Signed-off-by: Innei <tukon479@gmail.com> feat: enhance type-safe IPC flow with context propagation and service registry - Introduced `getIpcContext()` and `runWithIpcContext()` for improved context management in IPC handlers. - Updated `BrowserWindowsCtr` methods to utilize the new context handling. - Added `McpInstallCtr` to the IPC constructors registry. - Enhanced README with details on the new type-safe IPC features. Signed-off-by: Innei <tukon479@gmail.com> refactor: enhance IPC method registration for improved type safety - Updated `registerMethod` in `IpcHandler` and `IpcService` to accept variable argument types, enhancing flexibility in method signatures. - Simplified the `ExtractMethodSignature` type to support multiple arguments. Signed-off-by: Innei <tukon479@gmail.com> chore: add global type definitions and refactor import statements - Introduced a new global type definition file to support Vite client imports. - Refactored import statements in `App.ts` and `App.test.ts` to remove unnecessary type casting for `import.meta.glob`, improving code clarity. Signed-off-by: Innei <tukon479@gmail.com> * refactor: make groupName in BrowserWindowsCtr readonly for better encapsulation Signed-off-by: Innei <tukon479@gmail.com> * refactor: update IPC method registration and usage for improved type safety and consistency - Replaced `@ipcClientEvent` with `@IpcMethod()` in various controllers to standardize IPC method definitions. - Enhanced the usage of `ensureElectronIpc()` for type-safe IPC calls in service layers. - Updated `BrowserWindowsCtr` and `NotificationCtr` to utilize the new IPC method structure, improving encapsulation and clarity. - Refactored service methods to eliminate manual string concatenation for IPC event names, ensuring better maintainability. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -36,13 +36,13 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
1. **创建控制器 (Controller)**
|
||||
- 位置:`apps/desktop/src/main/controllers/`
|
||||
- 示例:创建 `NewFeatureCtr.ts`
|
||||
- 规范:按 `_template.ts` 模板格式实现
|
||||
- 注册:在 `apps/desktop/src/main/controllers/index.ts` 导出
|
||||
- 需继承 `ControllerModule`,并设置 `static readonly groupName`(例如 `static override readonly groupName = 'newFeature';`)
|
||||
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors`(或 `controllerServerIpcConstructors`)中注册,保证类型推导与自动装配
|
||||
|
||||
2. **定义 IPC 事件处理器**
|
||||
- 使用 `@ipcClientEvent('eventName')` 装饰器注册事件处理函数
|
||||
- 处理函数应接收前端传递的参数并返回结果
|
||||
- 处理可能的错误情况
|
||||
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道,或使用 `@IpcServerMethod()` 声明仅供 Next.js 服务器调用的 IPC
|
||||
- 通道名称基于 `groupName.methodName` 自动生成,不再手动拼接字符串
|
||||
- 处理函数可通过 `getIpcContext()` 获取 `sender`、`event` 等上下文信息,并按照需要返回结构化结果
|
||||
|
||||
3. **实现业务逻辑**
|
||||
- 可能需要调用 Electron API 或 Node.js 原生模块
|
||||
@@ -60,15 +60,17 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
1. **创建服务层**
|
||||
- 位置:`src/services/electron/`
|
||||
- 添加服务方法调用 IPC
|
||||
- 使用 `dispatch` 或 `invoke` 函数
|
||||
- 使用 `ensureElectronIpc()` 生成的类型安全代理,避免手动拼通道名称
|
||||
|
||||
```typescript
|
||||
// src/services/electron/newFeatureService.ts
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { NewFeatureParams } from 'types';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
import type { NewFeatureParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
export const newFeatureService = async (params: NewFeatureParams) => {
|
||||
return dispatch('newFeatureEventName', params);
|
||||
return ipc.newFeature.doSomething(params);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -118,36 +120,31 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/NotificationCtr.ts
|
||||
import { BrowserWindow, Notification } from 'electron';
|
||||
import { ipcClientEvent } from 'electron-client-ipc';
|
||||
import { Notification } from 'electron';
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
import type {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
interface ShowNotificationParams {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
static override readonly groupName = 'notification';
|
||||
|
||||
@IpcMethod()
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
if (!Notification.isSupported()) {
|
||||
return { error: 'Notifications not supported', success: false };
|
||||
}
|
||||
|
||||
export class NotificationCtr {
|
||||
@ipcClientEvent('showNotification')
|
||||
async handleShowNotification({ title, body }: ShowNotificationParams) {
|
||||
try {
|
||||
if (!Notification.isSupported()) {
|
||||
return { success: false, error: 'Notifications not supported' };
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
});
|
||||
|
||||
const notification = new Notification({ body: params.body, title: params.title });
|
||||
notification.show();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to show notification:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
console.error('[NotificationCtr] Failed to show notification:', error);
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error', success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,15 @@ alwaysApply: false
|
||||
* 导入在步骤 2 中定义的 IPC 参数类型。
|
||||
* 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
|
||||
* 方法接收 `params` (符合 IPC 参数类型)。
|
||||
* 使用从 `@lobechat/electron-client-ipc` 导入的 `dispatch` (或 `invoke`) 函数,调用与 Manifest 中 `name` 字段匹配的 IPC 事件名称,并将 `params` 传递过去。
|
||||
* 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
|
||||
* 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
|
||||
|
||||
5. **实现后端逻辑 (Controller / IPC Handler):**
|
||||
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
|
||||
* **操作:**
|
||||
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ipcClientEvent`, 参数类型等)。
|
||||
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`/`IpcServerMethod`、参数类型等)。
|
||||
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
|
||||
* 使用 `@ipcClientEvent('yourApiName')` 装饰器将此方法注册为对应 IPC 事件的处理器,确保 `'yourApiName'` 与 Manifest 中的 `name` 和 Service 层调用的事件名称一致。
|
||||
* 使用 `@IpcMethod()` 或 `@IpcServerMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
|
||||
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
|
||||
* 实现核心业务逻辑:
|
||||
* 进行必要的输入验证。
|
||||
|
||||
@@ -149,50 +149,52 @@ export const createMainWindow = () => {
|
||||
|
||||
1. **在主进程中注册 IPC 处理器**
|
||||
```typescript
|
||||
// BrowserWindowsCtr.ts
|
||||
@ipcClientEvent('minimizeWindow')
|
||||
handleMinimizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
focusedWindow.minimize();
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
@ipcClientEvent('maximizeWindow')
|
||||
handleMaximizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
if (focusedWindow.isMaximized()) {
|
||||
focusedWindow.restore();
|
||||
} else {
|
||||
focusedWindow.maximize();
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@ipcClientEvent('closeWindow')
|
||||
handleCloseWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
focusedWindow.close();
|
||||
@IpcMethod()
|
||||
minimizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
focusedWindow?.minimize();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
maximizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow?.isMaximized()) focusedWindow.restore();
|
||||
else focusedWindow?.maximize();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
closeWindow() {
|
||||
BrowserWindow.getFocusedWindow()?.close();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
- `@IpcMethod()` 根据控制器的 `groupName` 自动将方法映射为 `windows.minimizeWindow` 形式的通道名称。
|
||||
- 控制器需继承 `ControllerModule`,并在 `controllers/registry.ts` 中通过 `controllerIpcConstructors` 注册,便于类型生成。
|
||||
|
||||
2. **在渲染进程中调用**
|
||||
```typescript
|
||||
// src/services/electron/windowService.ts
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
export const windowService = {
|
||||
minimize: () => dispatch('minimizeWindow'),
|
||||
maximize: () => dispatch('maximizeWindow'),
|
||||
close: () => dispatch('closeWindow'),
|
||||
minimize: () => ipc.windows.minimizeWindow(),
|
||||
maximize: () => ipc.windows.maximizeWindow(),
|
||||
close: () => ipc.windows.closeWindow(),
|
||||
};
|
||||
```
|
||||
- `ensureElectronIpc()` 会基于 `DesktopIpcServices` 运行时生成 Proxy,并通过 `window.electronAPI.invoke` 与主进程通信;不再直接使用 `dispatch`。
|
||||
|
||||
### 5. 自定义窗口控制 (无边框窗口)
|
||||
|
||||
@@ -252,45 +254,33 @@ export const createMainWindow = () => {
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
|
||||
import type { OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@IpcMethod()
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
? { tab: typeof options === 'string' ? options : undefined }
|
||||
: options;
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const query = new URLSearchParams();
|
||||
if (normalizedOptions.tab) query.set('active', normalizedOptions.tab);
|
||||
if (normalizedOptions.searchParams) {
|
||||
for (const [key, value] of Object.entries(normalizedOptions.searchParams)) {
|
||||
if (value) query.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = `/settings${query.size ? `?${query.toString()}` : ''}`;
|
||||
await mainWindow.loadUrl(fullPath);
|
||||
mainWindow.show();
|
||||
|
||||
@ipcClientEvent('openSettings')
|
||||
handleOpenSettings() {
|
||||
// 检查设置窗口是否已经存在
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
// 如果窗口已存在,将其置于前台
|
||||
this.settingsWindow.focus();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 创建新窗口
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
title: 'Settings',
|
||||
parent: this.mainWindow, // 设置父窗口,使其成为模态窗口
|
||||
modal: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 加载设置页面
|
||||
if (isDev) {
|
||||
this.settingsWindow.loadURL('http://localhost:3000/settings');
|
||||
} else {
|
||||
this.settingsWindow.loadFile(
|
||||
path.join(__dirname, '../../renderer/index.html'),
|
||||
{ hash: 'settings' }
|
||||
);
|
||||
}
|
||||
|
||||
// 监听窗口关闭事件
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null;
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
+38
-42
@@ -156,24 +156,26 @@ apps/desktop/src/main/
|
||||
- 事件广播:向渲染进程通知授权状态变化
|
||||
|
||||
```typescript
|
||||
// 认证流程示例
|
||||
@ipcClientEvent('requestAuthorization')
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
// 生成状态参数防止 CSRF 攻击
|
||||
this.authRequestState = crypto.randomBytes(16).toString('hex');
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
// 构建授权 URL
|
||||
const authUrl = new URL('/oidc/auth', remoteUrl);
|
||||
authUrl.search = querystring.stringify({
|
||||
client_id: 'lobe-chat',
|
||||
response_type: 'code',
|
||||
redirect_uri: `${protocolPrefix}://auth/callback`,
|
||||
scope: 'openid profile',
|
||||
state: this.authRequestState,
|
||||
});
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
static override groupName = 'auth';
|
||||
|
||||
// 在默认浏览器中打开授权 URL
|
||||
await shell.openExternal(authUrl.toString());
|
||||
@IpcMethod()
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
this.authRequestState = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const authUrl = new URL('/oidc/auth', remoteUrl);
|
||||
authUrl.search = querystring.stringify({
|
||||
client_id: 'lobe-chat',
|
||||
redirect_uri: `${protocolPrefix}://auth/callback`,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile',
|
||||
state: this.authRequestState,
|
||||
});
|
||||
|
||||
await shell.openExternal(authUrl.toString());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,20 +269,27 @@ export class ShortcutManager {
|
||||
- 注入 App 实例
|
||||
|
||||
```typescript
|
||||
// 控制器基类和装饰器
|
||||
import { ControllerModule, IpcMethod, IpcServerMethod } from '@/controllers'
|
||||
|
||||
export class ControllerModule implements IControllerModule {
|
||||
constructor(public app: App) {
|
||||
this.app = app;
|
||||
this.app = app
|
||||
}
|
||||
}
|
||||
|
||||
// IPC 客户端事件装饰器
|
||||
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
|
||||
ipcDecorator(method, 'client');
|
||||
export class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows' // must be readonly
|
||||
|
||||
// IPC 服务器事件装饰器
|
||||
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
|
||||
ipcDecorator(method, 'server');
|
||||
@IpcMethod()
|
||||
openSettingsWindow(params?: OpenSettingsWindowOptions) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
handleServerCommand(payload: any) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **IoC 容器**:
|
||||
@@ -346,26 +355,13 @@ makeSureDirExist(storagePath);
|
||||
- 自动映射控制器方法到 IPC 事件
|
||||
|
||||
```typescript
|
||||
// IPC 事件初始化
|
||||
private initializeIPCEvents() {
|
||||
// 注册客户端事件处理程序
|
||||
this.ipcClientEventMap.forEach((eventInfo, key) => {
|
||||
ipcMain.handle(key, async (e, ...data) => {
|
||||
return await eventInfo.controller[eventInfo.methodName](...data);
|
||||
});
|
||||
});
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
// 注册服务器事件处理程序
|
||||
const ipcServerEvents = {} as ElectronIPCEventHandler;
|
||||
this.ipcServerEventMap.forEach((eventInfo, key) => {
|
||||
ipcServerEvents[key] = async (payload) => {
|
||||
return await eventInfo.controller[eventInfo.methodName](payload);
|
||||
};
|
||||
});
|
||||
// 渲染进程中使用 type-safe proxy 调用主进程方法
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
// 创建 IPC 服务器
|
||||
this.ipcServer = new ElectronIPCServer(name, ipcServerEvents);
|
||||
}
|
||||
await ipc.localSystem.readLocalFile({ path });
|
||||
await ipc.system.updateLocale('en-US');
|
||||
```
|
||||
|
||||
2. **事件广播**:
|
||||
|
||||
+37
-1
@@ -183,10 +183,18 @@ 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
|
||||
- **Decorator Registration** - `@ipcClientEvent` and `@ipcServerEvent` decorators
|
||||
- **Typed IPC Decorators** - `@IpcMethod` and `@IpcServerMethod` wire 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.
|
||||
- **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
|
||||
|
||||
- **Theme-Aware Windows** - Automatic adaptation to system dark/light mode
|
||||
@@ -235,6 +243,7 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
|
||||
|
||||
#### 🎮 Controller Pattern
|
||||
|
||||
- **Typed IPC Decorators** - Controllers extend `ControllerModule` and expose renderer methods via `@IpcMethod`
|
||||
- **IPC Event Handling** - Processes events from renderer with decorator-based registration
|
||||
- **Lifecycle Hooks** - `beforeAppReady` and `afterAppReady` for initialization phases
|
||||
- **Type-Safe Communication** - Strong typing for all IPC events and responses
|
||||
@@ -256,6 +265,33 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
|
||||
- **Context Awareness** - Events include sender context for window-specific operations
|
||||
- **Error Propagation** - Centralized error handling with proper status codes
|
||||
|
||||
##### 🧩 Renderer IPC Helper
|
||||
|
||||
Renderer code uses a lightweight proxy generated at runtime to keep IPC calls type-safe without exposing raw Electron objects through `contextBridge`. Use the helper exported from `src/utils/electron/ipc.ts` to access the main-process services:
|
||||
|
||||
```ts
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
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 的装饰控制器方法容器
|
||||
- **装饰器注册** - `@ipcClientEvent` 和 `@ipcServerEvent` 装饰器
|
||||
- **装饰器注册** - `@IpcMethod` 和 `@IpcServerMethod` 装饰器
|
||||
- **自动事件映射** - 控制器加载期间注册的事件
|
||||
- **服务定位器** - 类型安全的服务和控制器检索
|
||||
|
||||
@@ -256,6 +256,31 @@ src/main/core/
|
||||
- **上下文感知** - 事件包含用于窗口特定操作的发送者上下文
|
||||
- **错误传播** - 具有适当状态码的集中错误处理
|
||||
|
||||
##### 🧩 渲染器 IPC 助手
|
||||
|
||||
渲染端通过 `src/utils/electron/ipc.ts` 提供的 `ensureElectronIpc` 获得一个运行时代理,无需在 preload 中暴露 Proxy 对象即可获得类型安全的调用体验:
|
||||
|
||||
```ts
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
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** - 具有状态参数验证的安全认证
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~common': resolve(__dirname, 'src/common'),
|
||||
'@': resolve(__dirname, 'src/main'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { URL } from 'node:url';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:AuthCtr');
|
||||
@@ -17,6 +17,7 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
* Implements OAuth authorization flow using intermediate page + polling mechanism
|
||||
*/
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
static override readonly groupName = 'auth';
|
||||
/**
|
||||
* Remote server configuration controller
|
||||
*/
|
||||
@@ -56,7 +57,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Request OAuth authorization
|
||||
*/
|
||||
@ipcClientEvent('requestAuthorization')
|
||||
@IpcMethod()
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
// Clear any old authorization state
|
||||
this.clearAuthorizationState();
|
||||
@@ -119,7 +120,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Request Market OAuth authorization (desktop)
|
||||
*/
|
||||
@ipcClientEvent('requestMarketAuthorization')
|
||||
@IpcMethod()
|
||||
async requestMarketAuthorization(params: MarketAuthorizationParams) {
|
||||
const { authUrl } = params;
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { findMatchingRoute } from '~common/routes';
|
||||
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
} from '@/appBrowsers';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import { AppBrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
|
||||
import { getIpcContext } from '@/utils/ipc';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, shortcut } from './index';
|
||||
import { ControllerModule, IpcMethod, shortcut } from './index';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@shortcut('showApp')
|
||||
async toggleMainWindow() {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
@ipcClientEvent('openSettingsWindow')
|
||||
@IpcMethod()
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions: OpenSettingsWindowOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
@@ -53,26 +52,32 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('closeWindow')
|
||||
closeWindow(data: undefined, sender: IpcClientEventSender) {
|
||||
this.app.browserManager.closeWindow(sender.identifier);
|
||||
@IpcMethod()
|
||||
closeWindow() {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.closeWindow(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
@ipcClientEvent('minimizeWindow')
|
||||
minimizeWindow(data: undefined, sender: IpcClientEventSender) {
|
||||
this.app.browserManager.minimizeWindow(sender.identifier);
|
||||
@IpcMethod()
|
||||
minimizeWindow() {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.minimizeWindow(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
@ipcClientEvent('maximizeWindow')
|
||||
maximizeWindow(data: undefined, sender: IpcClientEventSender) {
|
||||
this.app.browserManager.maximizeWindow(sender.identifier);
|
||||
@IpcMethod()
|
||||
maximizeWindow() {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.maximizeWindow(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route interception requests
|
||||
* Responsible for handling route interception requests from the renderer process
|
||||
*/
|
||||
@ipcClientEvent('interceptRoute')
|
||||
@IpcMethod()
|
||||
async interceptRoute(params: InterceptRouteParams) {
|
||||
const { path, source } = params;
|
||||
console.log(
|
||||
@@ -115,7 +120,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
/**
|
||||
* Create a new multi-instance window
|
||||
*/
|
||||
@ipcClientEvent('createMultiInstanceWindow')
|
||||
@IpcMethod()
|
||||
async createMultiInstanceWindow(params: {
|
||||
path: string;
|
||||
templateId: WindowTemplateIdentifiers;
|
||||
@@ -149,7 +154,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
/**
|
||||
* Get all windows by template
|
||||
*/
|
||||
@ipcClientEvent('getWindowsByTemplate')
|
||||
@IpcMethod()
|
||||
async getWindowsByTemplate(templateId: string) {
|
||||
try {
|
||||
const windowIds = this.app.browserManager.getWindowsByTemplate(templateId);
|
||||
@@ -169,7 +174,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
/**
|
||||
* Close all windows by template
|
||||
*/
|
||||
@ipcClientEvent('closeWindowsByTemplate')
|
||||
@IpcMethod()
|
||||
async closeWindowsByTemplate(templateId: string) {
|
||||
try {
|
||||
this.app.browserManager.closeWindowsByTemplate(templateId);
|
||||
@@ -191,4 +196,12 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
const browser = this.app.browserManager.retrieveByIdentifier(targetWindow);
|
||||
browser.show();
|
||||
}
|
||||
|
||||
private withSenderIdentifier(fn: (identifier: string) => void) {
|
||||
const context = getIpcContext();
|
||||
if (!context) return;
|
||||
const identifier = this.app.browserManager.getIdentifierByWebContents(context.sender);
|
||||
if (!identifier) return;
|
||||
fn(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class DevtoolsCtr extends ControllerModule {
|
||||
@ipcClientEvent('openDevtools')
|
||||
static override readonly groupName = 'devtools';
|
||||
|
||||
@IpcMethod()
|
||||
async openDevtools() {
|
||||
const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
|
||||
devtoolsBrowser.show();
|
||||
|
||||
@@ -30,19 +30,20 @@ import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:LocalFileCtr');
|
||||
|
||||
export default class LocalFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'localSystem';
|
||||
private get searchService() {
|
||||
return this.app.getService(FileSearchService);
|
||||
}
|
||||
|
||||
// ==================== File Operation ====================
|
||||
|
||||
@ipcClientEvent('openLocalFile')
|
||||
@IpcMethod()
|
||||
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
|
||||
error?: string;
|
||||
success: boolean;
|
||||
@@ -59,7 +60,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('openLocalFolder')
|
||||
@IpcMethod()
|
||||
async handleOpenLocalFolder({ path: targetPath, isDirectory }: OpenLocalFolderParams): Promise<{
|
||||
error?: string;
|
||||
success: boolean;
|
||||
@@ -77,7 +78,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('readLocalFiles')
|
||||
@IpcMethod()
|
||||
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
|
||||
logger.debug('Starting batch file reading:', { count: paths.length });
|
||||
|
||||
@@ -94,7 +95,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return results;
|
||||
}
|
||||
|
||||
@ipcClientEvent('readLocalFile')
|
||||
@IpcMethod()
|
||||
async readFile({
|
||||
path: filePath,
|
||||
loc,
|
||||
@@ -192,7 +193,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('listLocalFiles')
|
||||
@IpcMethod()
|
||||
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
|
||||
logger.debug('Listing directory contents:', { dirPath });
|
||||
|
||||
@@ -250,7 +251,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('moveLocalFiles')
|
||||
@IpcMethod()
|
||||
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
|
||||
logger.debug('Starting batch file move:', { itemsCount: items?.length });
|
||||
|
||||
@@ -355,7 +356,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return results;
|
||||
}
|
||||
|
||||
@ipcClientEvent('renameLocalFile')
|
||||
@IpcMethod()
|
||||
async handleRenameFile({
|
||||
path: currentPath,
|
||||
newName,
|
||||
@@ -440,7 +441,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('writeLocalFile')
|
||||
@IpcMethod()
|
||||
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
|
||||
const logPrefix = `[Writing file ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
|
||||
@@ -485,7 +486,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
@IpcMethod()
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
@@ -523,7 +524,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('grepContent')
|
||||
@IpcMethod()
|
||||
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
@@ -639,7 +640,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('globLocalFiles')
|
||||
@IpcMethod()
|
||||
async handleGlobFiles({
|
||||
path: searchPath = process.cwd(),
|
||||
pattern,
|
||||
@@ -680,7 +681,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
// ==================== File Editing ====================
|
||||
|
||||
@ipcClientEvent('editLocalFile')
|
||||
@IpcMethod()
|
||||
async handleEditFile({
|
||||
file_path: filePath,
|
||||
new_string,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class MenuController extends ControllerModule {
|
||||
static override readonly groupName = 'menu';
|
||||
/**
|
||||
* Refresh menu
|
||||
*/
|
||||
@ipcClientEvent('refreshAppMenu')
|
||||
@IpcMethod()
|
||||
refreshAppMenu() {
|
||||
// Note: May need to decide whether to allow renderer process to refresh all menus based on specific circumstances
|
||||
return this.app.menuManager.refreshMenus();
|
||||
@@ -13,7 +14,7 @@ export default class MenuController extends ControllerModule {
|
||||
/**
|
||||
* Show context menu
|
||||
*/
|
||||
@ipcClientEvent('showContextMenu')
|
||||
@IpcMethod()
|
||||
showContextMenu(params: { data?: any; type: string }) {
|
||||
return this.app.menuManager.showContextMenu(params.type, params.data);
|
||||
}
|
||||
@@ -21,7 +22,7 @@ export default class MenuController extends ControllerModule {
|
||||
/**
|
||||
* Set development menu visibility
|
||||
*/
|
||||
@ipcClientEvent('setDevMenuVisibility')
|
||||
@IpcMethod()
|
||||
setDevMenuVisibility(visible: boolean) {
|
||||
// Call MenuManager method to rebuild application menu
|
||||
return this.app.menuManager.rebuildAppMenu({ showDevItems: visible });
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ProxyDispatcherManager,
|
||||
ProxyTestResult,
|
||||
} from '../modules/networkProxy';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:NetworkProxyCtr');
|
||||
@@ -21,10 +21,11 @@ const logger = createLogger('controllers:NetworkProxyCtr');
|
||||
* 处理桌面应用的网络代理相关功能
|
||||
*/
|
||||
export default class NetworkProxyCtr extends ControllerModule {
|
||||
static override readonly groupName = 'networkProxy';
|
||||
/**
|
||||
* 获取代理设置
|
||||
*/
|
||||
@ipcClientEvent('getProxySettings')
|
||||
@IpcMethod()
|
||||
async getDesktopSettings(): Promise<NetworkProxySettings> {
|
||||
try {
|
||||
const settings = this.app.storeManager.get(
|
||||
@@ -45,32 +46,30 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 设置代理配置
|
||||
*/
|
||||
@ipcClientEvent('setProxySettings')
|
||||
async setProxySettings(config: NetworkProxySettings): Promise<void> {
|
||||
@IpcMethod()
|
||||
async setProxySettings(config: Partial<NetworkProxySettings>): Promise<void> {
|
||||
try {
|
||||
// 验证配置
|
||||
const validation = ProxyConfigValidator.validate(config);
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 获取当前配置
|
||||
const currentConfig = this.app.storeManager.get(
|
||||
'networkProxy',
|
||||
defaultProxySettings,
|
||||
) as NetworkProxySettings;
|
||||
|
||||
// 检查是否有变化
|
||||
if (isEqual(currentConfig, config)) {
|
||||
// 合并配置并验证
|
||||
const newConfig = merge({}, currentConfig, config);
|
||||
|
||||
const validation = ProxyConfigValidator.validate(newConfig);
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (isEqual(currentConfig, newConfig)) {
|
||||
logger.debug('Proxy settings unchanged, skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const newConfig = merge({}, currentConfig, config);
|
||||
|
||||
// 应用代理设置
|
||||
await ProxyDispatcherManager.applyProxySettings(newConfig);
|
||||
|
||||
@@ -92,7 +91,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 测试代理连接
|
||||
*/
|
||||
@ipcClientEvent('testProxyConnection')
|
||||
@IpcMethod()
|
||||
async testProxyConnection(url: string): Promise<{ message?: string; success: boolean }> {
|
||||
try {
|
||||
const result = await ProxyConnectionTester.testConnection(url);
|
||||
@@ -112,7 +111,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 测试指定代理配置
|
||||
*/
|
||||
@ipcClientEvent('testProxyConfig')
|
||||
@IpcMethod()
|
||||
async testProxyConfig({
|
||||
config,
|
||||
testUrl,
|
||||
|
||||
@@ -7,11 +7,12 @@ import { macOS, windows } from 'electron-is';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:NotificationCtr');
|
||||
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
static override readonly groupName = 'notification';
|
||||
/**
|
||||
* Set up desktop notifications after the application is ready
|
||||
*/
|
||||
@@ -51,7 +52,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
/**
|
||||
* Show system desktop notification (only when window is hidden)
|
||||
*/
|
||||
@ipcClientEvent('showDesktopNotification')
|
||||
@IpcMethod()
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
@@ -126,7 +127,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
/**
|
||||
* Check if the main window is hidden
|
||||
*/
|
||||
@ipcClientEvent('isMainWindowHidden')
|
||||
@IpcMethod()
|
||||
isMainWindowHidden(): boolean {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { URL } from 'node:url';
|
||||
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
/**
|
||||
* Non-retryable OIDC error codes
|
||||
@@ -39,6 +39,7 @@ const logger = createLogger('controllers:RemoteServerConfigCtr');
|
||||
* Used to manage custom remote LobeChat server configuration
|
||||
*/
|
||||
export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
static override readonly groupName = 'remoteServer';
|
||||
/**
|
||||
* Key used to store encrypted tokens in electron-store.
|
||||
*/
|
||||
@@ -47,7 +48,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
/**
|
||||
* Get remote server configuration
|
||||
*/
|
||||
@ipcClientEvent('getRemoteServerConfig')
|
||||
@IpcMethod()
|
||||
async getRemoteServerConfig() {
|
||||
logger.debug('Getting remote server configuration');
|
||||
const { storeManager } = this.app;
|
||||
@@ -64,7 +65,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
/**
|
||||
* Set remote server configuration
|
||||
*/
|
||||
@ipcClientEvent('setRemoteServerConfig')
|
||||
@IpcMethod()
|
||||
async setRemoteServerConfig(config: Partial<DataSyncConfig>) {
|
||||
logger.info(
|
||||
`Setting remote server storageMode: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
|
||||
@@ -81,7 +82,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
/**
|
||||
* Clear remote server configuration
|
||||
*/
|
||||
@ipcClientEvent('clearRemoteServerConfig')
|
||||
@IpcMethod()
|
||||
async clearRemoteServerConfig() {
|
||||
logger.info('Clearing remote server configuration');
|
||||
const { storeManager } = this.app;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { defaultProxySettings } from '@/const/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:RemoteServerSyncCtr');
|
||||
@@ -25,6 +25,7 @@ const logger = createLogger('controllers:RemoteServerSyncCtr');
|
||||
* For handling data synchronization with remote servers via IPC.
|
||||
*/
|
||||
export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
static override readonly groupName = 'remoteServerSync';
|
||||
/**
|
||||
* Cached instance of RemoteServerConfigCtr
|
||||
*/
|
||||
@@ -345,7 +346,7 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
* Handles the 'proxy-trpc-request' IPC call from the renderer process.
|
||||
* This method should be invoked by the ipcMain.handle setup in your main process entry point.
|
||||
*/
|
||||
@ipcClientEvent('proxyTRPCRequest')
|
||||
@IpcMethod()
|
||||
public async proxyTRPCRequest(args: ProxyTRPCRequestParams): Promise<ProxyTRPCRequestResult> {
|
||||
logger.debug('Received proxyTRPCRequest IPC call:', {
|
||||
headers: args.headers,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ShellCommandCtr');
|
||||
|
||||
@@ -24,10 +24,11 @@ interface ShellProcess {
|
||||
}
|
||||
|
||||
export default class ShellCommandCtr extends ControllerModule {
|
||||
static override readonly groupName = 'shellCommand';
|
||||
// Shell process management
|
||||
private shellProcesses = new Map<string, ShellProcess>();
|
||||
|
||||
@ipcClientEvent('runCommand')
|
||||
@IpcMethod()
|
||||
async handleRunCommand({
|
||||
command,
|
||||
description,
|
||||
@@ -153,7 +154,7 @@ export default class ShellCommandCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('getCommandOutput')
|
||||
@IpcMethod()
|
||||
async handleGetCommandOutput({
|
||||
filter,
|
||||
shell_id,
|
||||
@@ -212,7 +213,7 @@ export default class ShellCommandCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
@ipcClientEvent('killCommand')
|
||||
@IpcMethod()
|
||||
async handleKillCommand({ shell_id }: KillCommandParams): Promise<KillCommandResult> {
|
||||
const logPrefix = `[killCommand: ${shell_id}]`;
|
||||
logger.debug(`${logPrefix} Attempting to kill shell`);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ShortcutUpdateResult } from '@/core/ui/ShortcutManager';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from '.';
|
||||
import { ControllerModule, IpcMethod } from '.';
|
||||
|
||||
export default class ShortcutController extends ControllerModule {
|
||||
static override readonly groupName = 'shortcut';
|
||||
/**
|
||||
* Get all shortcut configurations
|
||||
*/
|
||||
@ipcClientEvent('getShortcutsConfig')
|
||||
@IpcMethod()
|
||||
getShortcutsConfig() {
|
||||
return this.app.shortcutManager.getShortcutsConfig();
|
||||
}
|
||||
@@ -14,7 +15,7 @@ export default class ShortcutController extends ControllerModule {
|
||||
/**
|
||||
* Update a single shortcut configuration
|
||||
*/
|
||||
@ipcClientEvent('updateShortcutConfig')
|
||||
@IpcMethod()
|
||||
updateShortcutConfig({
|
||||
id,
|
||||
accelerator,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import { app, nativeTheme, shell, systemPreferences } from 'electron';
|
||||
import { macOS } from 'electron-is';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:SystemCtr');
|
||||
|
||||
export default class SystemController extends ControllerModule {
|
||||
static override readonly groupName = 'system';
|
||||
private systemThemeListenerInitialized = false;
|
||||
|
||||
/**
|
||||
@@ -26,7 +24,7 @@ export default class SystemController extends ControllerModule {
|
||||
* Handles the 'getDesktopAppState' IPC request.
|
||||
* Gathers essential application and system information.
|
||||
*/
|
||||
@ipcClientEvent('getDesktopAppState')
|
||||
@IpcMethod()
|
||||
async getAppState(): Promise<ElectronAppState> {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
@@ -56,13 +54,13 @@ export default class SystemController extends ControllerModule {
|
||||
/**
|
||||
* 检查可用性
|
||||
*/
|
||||
@ipcClientEvent('checkSystemAccessibility')
|
||||
@IpcMethod()
|
||||
checkAccessibilityForMacOS() {
|
||||
if (!macOS()) return;
|
||||
return systemPreferences.isTrustedAccessibilityClient(true);
|
||||
}
|
||||
|
||||
@ipcClientEvent('openExternalLink')
|
||||
@IpcMethod()
|
||||
openExternalLink(url: string) {
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
@@ -70,7 +68,7 @@ export default class SystemController extends ControllerModule {
|
||||
/**
|
||||
* 更新应用语言设置
|
||||
*/
|
||||
@ipcClientEvent('updateLocale')
|
||||
@IpcMethod()
|
||||
async updateLocale(locale: string) {
|
||||
// 保存语言设置
|
||||
this.app.storeManager.set('locale', locale);
|
||||
@@ -82,7 +80,7 @@ export default class SystemController extends ControllerModule {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ipcClientEvent('updateThemeMode')
|
||||
@IpcMethod()
|
||||
async updateThemeModeHandler(themeMode: ThemeMode) {
|
||||
this.app.storeManager.set('themeMode', themeMode);
|
||||
this.app.browserManager.broadcastToAllWindows('themeChanged', { themeMode });
|
||||
@@ -91,34 +89,6 @@ export default class SystemController extends ControllerModule {
|
||||
this.app.browserManager.handleAppThemeChange();
|
||||
}
|
||||
|
||||
@ipcServerEvent('getDatabasePath')
|
||||
async getDatabasePath() {
|
||||
return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
}
|
||||
|
||||
@ipcServerEvent('getDatabaseSchemaHash')
|
||||
async getDatabaseSchemaHash() {
|
||||
try {
|
||||
return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ipcServerEvent('getUserDataPath')
|
||||
async getUserDataPath() {
|
||||
return userDataDir;
|
||||
}
|
||||
|
||||
@ipcServerEvent('setDatabaseSchemaHash')
|
||||
async setDatabaseSchemaHash(hash: string) {
|
||||
writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8');
|
||||
}
|
||||
|
||||
private get DB_SCHEMA_HASH_PATH() {
|
||||
return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize system theme listener to monitor OS theme changes
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
|
||||
|
||||
import { ControllerModule, IpcServerMethod } from './index';
|
||||
|
||||
export default class SystemServerCtr extends ControllerModule {
|
||||
static override readonly groupName = 'system';
|
||||
|
||||
@IpcServerMethod()
|
||||
async getDatabasePath() {
|
||||
return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getDatabaseSchemaHash() {
|
||||
try {
|
||||
return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getUserDataPath() {
|
||||
return userDataDir;
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async setDatabaseSchemaHash(hash: string) {
|
||||
writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8');
|
||||
}
|
||||
|
||||
private get DB_SCHEMA_HASH_PATH() {
|
||||
return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:TrayMenuCtr');
|
||||
|
||||
export default class TrayMenuCtr extends ControllerModule {
|
||||
static override readonly groupName = 'tray';
|
||||
async toggleMainWindow() {
|
||||
logger.debug('Toggle main window visibility via shortcut');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
@@ -23,7 +24,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
* @param options Balloon options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('showTrayNotification')
|
||||
@IpcMethod()
|
||||
async showNotification(options: ShowTrayNotificationParams) {
|
||||
logger.debug('Show tray balloon notification');
|
||||
|
||||
@@ -52,7 +53,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
* @param options Icon options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('updateTrayIcon')
|
||||
@IpcMethod()
|
||||
async updateTrayIcon(options: UpdateTrayIconParams) {
|
||||
logger.debug('Update tray icon');
|
||||
|
||||
@@ -84,7 +85,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
* @param options Tooltip text options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('updateTrayTooltip')
|
||||
@IpcMethod()
|
||||
async updateTrayTooltip(options: UpdateTrayTooltipParams) {
|
||||
logger.debug('Update tray tooltip text');
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:UpdaterCtr');
|
||||
|
||||
export default class UpdaterCtr extends ControllerModule {
|
||||
static override readonly groupName = 'autoUpdate';
|
||||
/**
|
||||
* Check for updates
|
||||
*/
|
||||
@ipcClientEvent('checkUpdate')
|
||||
@IpcMethod()
|
||||
async checkForUpdates() {
|
||||
logger.info('Check for updates requested');
|
||||
await this.app.updaterManager.checkForUpdates();
|
||||
@@ -17,7 +18,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* Download update
|
||||
*/
|
||||
@ipcClientEvent('downloadUpdate')
|
||||
@IpcMethod()
|
||||
async downloadUpdate() {
|
||||
logger.info('Download update requested');
|
||||
await this.app.updaterManager.downloadUpdate();
|
||||
@@ -26,7 +27,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* Quit application and install update
|
||||
*/
|
||||
@ipcClientEvent('installNow')
|
||||
@IpcMethod()
|
||||
quitAndInstallUpdate() {
|
||||
logger.info('Quit and install update requested');
|
||||
this.app.updaterManager.installNow();
|
||||
@@ -35,7 +36,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* Install update on next startup
|
||||
*/
|
||||
@ipcClientEvent('installLater')
|
||||
@IpcMethod()
|
||||
installLater() {
|
||||
logger.info('Install later requested');
|
||||
this.app.updaterManager.installLater();
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
import { UploadFileParams } from '@lobechat/electron-client-ipc';
|
||||
import { CreateFileParams } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class UploadFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@ipcClientEvent('createFile')
|
||||
@IpcMethod()
|
||||
async uploadFile(params: UploadFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
|
||||
// ======== server event
|
||||
|
||||
@ipcServerEvent('getStaticFilePath')
|
||||
async getFileUrlById(id: string) {
|
||||
return this.fileService.getFilePath(id);
|
||||
}
|
||||
|
||||
@ipcServerEvent('getFileHTTPURL')
|
||||
async getFileHTTPURL(path: string) {
|
||||
return this.fileService.getFileHTTPURL(path);
|
||||
}
|
||||
|
||||
@ipcServerEvent('deleteFiles')
|
||||
async deleteFiles(paths: string[]) {
|
||||
return this.fileService.deleteFiles(paths);
|
||||
}
|
||||
|
||||
@ipcServerEvent('createFile')
|
||||
async createFile(params: CreateFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,18 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
@@ -99,6 +106,7 @@ describe('AuthCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
randomBytesCounter = 0; // Reset counter for each test
|
||||
|
||||
// Reset shell.openExternal to default successful behavior
|
||||
@@ -123,7 +131,7 @@ describe('AuthCtr', () => {
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up authCtr intervals (using real timers, not fake timers)
|
||||
authCtr.cleanup();
|
||||
authCtr?.cleanup?.();
|
||||
// Clean up any fake timers if used
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
@@ -3,10 +3,21 @@ import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { runWithIpcContext } from '@/utils/ipc';
|
||||
|
||||
import BrowserWindowsCtr from '../BrowserWindowsCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockToggleVisible = vi.fn();
|
||||
const mockLoadUrl = vi.fn();
|
||||
@@ -16,6 +27,9 @@ const mockCloseWindow = vi.fn();
|
||||
const mockMinimizeWindow = vi.fn();
|
||||
const mockMaximizeWindow = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn();
|
||||
const testSenderIdentifierString: string = 'test-window-event-id';
|
||||
|
||||
const mockGetIdentifierByWebContents = vi.fn(() => testSenderIdentifierString);
|
||||
const mockGetMainWindow = vi.fn(() => ({
|
||||
toggleVisible: mockToggleVisible,
|
||||
loadUrl: mockLoadUrl,
|
||||
@@ -32,6 +46,7 @@ const { findMatchingRoute } = await import('~common/routes');
|
||||
|
||||
const mockApp = {
|
||||
browserManager: {
|
||||
getIdentifierByWebContents: mockGetIdentifierByWebContents,
|
||||
getMainWindow: mockGetMainWindow,
|
||||
redirectToPage: mockRedirectToPage,
|
||||
closeWindow: mockCloseWindow,
|
||||
@@ -53,6 +68,7 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
browserWindowsCtr = new BrowserWindowsCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -82,28 +98,32 @@ describe('BrowserWindowsCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const testSenderIdentifierString: string = 'test-window-event-id';
|
||||
const sender: IpcClientEventSender = {
|
||||
identifier: testSenderIdentifierString,
|
||||
};
|
||||
|
||||
describe('closeWindow', () => {
|
||||
it('should close the window with the given sender identifier', () => {
|
||||
browserWindowsCtr.closeWindow(undefined, sender);
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
runWithIpcContext(context, () => browserWindowsCtr.closeWindow());
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockCloseWindow).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
});
|
||||
});
|
||||
|
||||
describe('minimizeWindow', () => {
|
||||
it('should minimize the window with the given sender identifier', () => {
|
||||
browserWindowsCtr.minimizeWindow(undefined, sender);
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
runWithIpcContext(context, () => browserWindowsCtr.minimizeWindow());
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockMinimizeWindow).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maximizeWindow', () => {
|
||||
it('should maximize the window with the given sender identifier', () => {
|
||||
browserWindowsCtr.maximizeWindow(undefined, sender);
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
runWithIpcContext(context, () => browserWindowsCtr.maximizeWindow());
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockMaximizeWindow).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import DevtoolsCtr from '../DevtoolsCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockShow = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn(() => ({
|
||||
@@ -24,10 +34,9 @@ describe('DevtoolsCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 只清除 vi.fn() 创建的模拟函数的记录,不影响 IoCContainer 状态
|
||||
ipcMainHandleMock.mockClear();
|
||||
|
||||
// 实例化 DevtoolsCtr。
|
||||
// 它将继承自真实的 ControllerModule。
|
||||
// 其 @ipcClientEvent 装饰器会执行并与真实的 IoCContainer 交互。
|
||||
// 实例化 DevtoolsCtr。其 @IpcMethod 装饰器会执行并与真实的 IoCContainer 交互。
|
||||
devtoolsCtr = new DevtoolsCtr(mockApp);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -22,6 +26,9 @@ vi.mock('@lobechat/file-loaders', () => ({
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import MenuController from '../MenuCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockRefreshMenus = vi.fn();
|
||||
const mockShowContextMenu = vi.fn();
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import NetworkProxyCtr from '../NetworkProxyCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// 模拟 logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -54,6 +58,7 @@ describe('NetworkProxyCtr', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
|
||||
// 动态导入 undici Mock
|
||||
mockUndici = await import('undici');
|
||||
@@ -418,3 +423,8 @@ describe('NetworkProxyCtr', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import NotificationCtr from '../NotificationCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -25,6 +29,9 @@ vi.mock('electron', () => {
|
||||
MockNotification.isSupported = vi.fn(() => true);
|
||||
|
||||
return {
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
Notification: MockNotification,
|
||||
app: {
|
||||
setAppUserModelId: vi.fn(),
|
||||
@@ -65,6 +72,7 @@ describe('NotificationCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
controller = new NotificationCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -17,6 +21,9 @@ vi.mock('@/utils/logger', () => ({
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
safeStorage: {
|
||||
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
||||
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
||||
@@ -45,6 +52,7 @@ describe('RemoteServerConfigCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'local',
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock('electron', () => ({
|
||||
getPath: vi.fn(() => '/mock/user/data'),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import ShellCommandCtr from '../ShellCommandCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import ShortcutController from '../ShortcutCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
|
||||
toggleMainWindow: 'CommandOrControl+Shift+L',
|
||||
@@ -26,6 +36,7 @@ describe('ShortcutController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
shortcutController = new ShortcutController(mockApp);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,38 @@ import { ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import SystemController from '../SystemCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(
|
||||
channel: string,
|
||||
payload?: any,
|
||||
context?: Partial<IpcContext>,
|
||||
): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = {
|
||||
sender: context?.sender ?? ({ id: 'test' } as any),
|
||||
};
|
||||
|
||||
if (payload === undefined) {
|
||||
return handler(fakeEvent);
|
||||
}
|
||||
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -21,6 +50,9 @@ vi.mock('electron', () => ({
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn((name: string) => `/mock/path/${name}`),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
@@ -38,19 +70,6 @@ vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @/const/dir
|
||||
vi.mock('@/const/dir', () => ({
|
||||
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
|
||||
LOCAL_DATABASE_DIR: 'database',
|
||||
userDataDir: '/mock/user/data',
|
||||
}));
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserManager = {
|
||||
broadcastToAllWindows: vi.fn(),
|
||||
@@ -80,12 +99,15 @@ describe('SystemController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
controller = new SystemController(mockApp);
|
||||
});
|
||||
|
||||
describe('getAppState', () => {
|
||||
it('should return app state with system info', async () => {
|
||||
const result = await controller.getAppState();
|
||||
const result = await invokeIpc('system.getAppState');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
arch: expect.any(String),
|
||||
@@ -108,7 +130,7 @@ describe('SystemController', () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
|
||||
|
||||
const result = await controller.getAppState();
|
||||
const result = await invokeIpc('system.getAppState');
|
||||
|
||||
expect(result.systemAppearance).toBe('dark');
|
||||
|
||||
@@ -121,7 +143,7 @@ describe('SystemController', () => {
|
||||
it('should check accessibility on macOS', async () => {
|
||||
const { systemPreferences } = await import('electron');
|
||||
|
||||
controller.checkAccessibilityForMacOS();
|
||||
await invokeIpc('system.checkAccessibilityForMacOS');
|
||||
|
||||
expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
|
||||
});
|
||||
@@ -130,7 +152,7 @@ describe('SystemController', () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = controller.checkAccessibilityForMacOS();
|
||||
const result = await invokeIpc('system.checkAccessibilityForMacOS');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -143,7 +165,7 @@ describe('SystemController', () => {
|
||||
it('should open external link', async () => {
|
||||
const { shell } = await import('electron');
|
||||
|
||||
await controller.openExternalLink('https://example.com');
|
||||
await invokeIpc('system.openExternalLink', 'https://example.com');
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
@@ -151,7 +173,7 @@ describe('SystemController', () => {
|
||||
|
||||
describe('updateLocale', () => {
|
||||
it('should update locale and broadcast change', async () => {
|
||||
const result = await controller.updateLocale('zh-CN');
|
||||
const result = await invokeIpc('system.updateLocale', 'zh-CN');
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('locale', 'zh-CN');
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('zh-CN');
|
||||
@@ -162,7 +184,7 @@ describe('SystemController', () => {
|
||||
});
|
||||
|
||||
it('should use system locale when set to auto', async () => {
|
||||
await controller.updateLocale('auto');
|
||||
await invokeIpc('system.updateLocale', 'auto');
|
||||
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('en-US');
|
||||
});
|
||||
@@ -172,7 +194,7 @@ describe('SystemController', () => {
|
||||
it('should update theme mode and broadcast change', async () => {
|
||||
const themeMode: ThemeMode = 'dark';
|
||||
|
||||
await controller.updateThemeModeHandler(themeMode);
|
||||
await invokeIpc('system.updateThemeModeHandler', themeMode);
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('themeMode', 'dark');
|
||||
expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('themeChanged', {
|
||||
@@ -182,58 +204,6 @@ describe('SystemController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabasePath', () => {
|
||||
it('should return database path', async () => {
|
||||
const result = await controller.getDatabasePath();
|
||||
|
||||
expect(result).toBe('/mock/storage/database');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabaseSchemaHash', () => {
|
||||
it('should return schema hash when file exists', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockReturnValue('abc123');
|
||||
|
||||
const result = await controller.getDatabaseSchemaHash();
|
||||
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
const result = await controller.getDatabaseSchemaHash();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserDataPath', () => {
|
||||
it('should return user data path', async () => {
|
||||
const result = await controller.getUserDataPath();
|
||||
|
||||
expect(result).toBe('/mock/user/data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDatabaseSchemaHash', () => {
|
||||
it('should write schema hash to file', async () => {
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
|
||||
await controller.setDatabaseSchemaHash('newhash123');
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
'/mock/storage/db-schema-hash.txt',
|
||||
'newhash123',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should initialize system theme listener', async () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import SystemServerCtr from '../SystemServerCtr';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
|
||||
LOCAL_DATABASE_DIR: 'database',
|
||||
userDataDir: '/mock/user/data',
|
||||
}));
|
||||
|
||||
const mockApp = {
|
||||
appStoragePath: '/mock/storage',
|
||||
} as unknown as App;
|
||||
|
||||
describe('SystemServerCtr', () => {
|
||||
let controller: SystemServerCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new SystemServerCtr(mockApp);
|
||||
});
|
||||
|
||||
it('returns database path', async () => {
|
||||
await expect(controller.getDatabasePath()).resolves.toBe('/mock/storage/database');
|
||||
});
|
||||
|
||||
it('reads schema hash when file exists', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockReturnValue('hash123');
|
||||
|
||||
await expect(controller.getDatabaseSchemaHash()).resolves.toBe('hash123');
|
||||
expect(readFileSync).toHaveBeenCalledWith('/mock/storage/db-schema-hash.txt', 'utf8');
|
||||
});
|
||||
|
||||
it('returns undefined when schema hash file missing', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
|
||||
await expect(controller.getDatabaseSchemaHash()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns user data path', async () => {
|
||||
await expect(controller.getUserDataPath()).resolves.toBe('/mock/user/data');
|
||||
});
|
||||
|
||||
it('writes schema hash to disk', async () => {
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
|
||||
await controller.setDatabaseSchemaHash('newhash');
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
'/mock/storage/db-schema-hash.txt',
|
||||
'newhash',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
import {
|
||||
ShowTrayNotificationParams,
|
||||
UpdateTrayIconParams,
|
||||
UpdateTrayTooltipParams
|
||||
UpdateTrayTooltipParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import TrayMenuCtr from '../TrayMenuCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -15,8 +27,6 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import TrayMenuCtr from '../TrayMenuCtr';
|
||||
|
||||
// 保存原始平台,确保测试结束后能恢复
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
@@ -45,6 +55,7 @@ describe('TrayMenuCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
// 为每个测试重置 mockedTray
|
||||
mockGetMainTray.mockReset();
|
||||
trayMenuCtr = new TrayMenuCtr(mockApp);
|
||||
@@ -69,7 +80,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should display balloon notification on Windows platform', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
displayBalloon: mockDisplayBalloon,
|
||||
};
|
||||
@@ -125,9 +136,9 @@ describe('TrayMenuCtr', () => {
|
||||
|
||||
expect(mockGetMainTray).toHaveBeenCalled();
|
||||
expect(mockDisplayBalloon).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
expect(result).toEqual({
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
success: false
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,7 +147,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should update tray icon on Windows platform', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
updateIcon: mockUpdateIcon,
|
||||
};
|
||||
@@ -156,7 +167,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should handle errors when updating icon', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const error = new Error('Failed to update icon');
|
||||
const mockedTray = {
|
||||
updateIcon: vi.fn().mockImplementation(() => {
|
||||
@@ -198,7 +209,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should update tray tooltip on Windows platform', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
updateTooltip: mockUpdateTooltip,
|
||||
};
|
||||
@@ -234,7 +245,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should return error when tooltip is not provided', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
updateTooltip: mockUpdateTooltip,
|
||||
};
|
||||
@@ -253,4 +264,4 @@ describe('TrayMenuCtr', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import UpdaterCtr from '../UpdaterCtr';
|
||||
|
||||
// 模拟 logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -9,7 +11,15 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import UpdaterCtr from '../UpdaterCtr';
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockCheckForUpdates = vi.fn();
|
||||
@@ -31,6 +41,7 @@ describe('UpdaterCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
updaterCtr = new UpdaterCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -79,4 +90,4 @@ describe('UpdaterCtr', () => {
|
||||
await expect(updaterCtr.downloadUpdate()).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = { sender: { id: 'test' } as any };
|
||||
if (payload === undefined) return handler(fakeEvent);
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
@@ -12,9 +36,6 @@ vi.mock('@/services/fileSrv', () => ({
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
getFilePath: vi.fn(),
|
||||
getFileHTTPURL: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
@@ -26,6 +47,9 @@ describe('UploadFileCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -41,7 +65,7 @@ describe('UploadFileCtr', () => {
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.uploadFile(params);
|
||||
const result = await invokeIpc('upload.uploadFile', params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
@@ -58,110 +82,7 @@ describe('UploadFileCtr', () => {
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.uploadFile(params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileUrlById', () => {
|
||||
it('should get file path by id successfully', async () => {
|
||||
const fileId = 'file-id-123';
|
||||
const expectedPath = '/files/abc123.txt';
|
||||
mockFileService.getFilePath.mockResolvedValue(expectedPath);
|
||||
|
||||
const result = await controller.getFileUrlById(fileId);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
expect(mockFileService.getFilePath).toHaveBeenCalledWith(fileId);
|
||||
});
|
||||
|
||||
it('should handle get file path error', async () => {
|
||||
const fileId = 'non-existent-id';
|
||||
const error = new Error('File not found');
|
||||
mockFileService.getFilePath.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getFileUrlById(fileId)).rejects.toThrow('File not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileHTTPURL', () => {
|
||||
it('should get file HTTP URL successfully', async () => {
|
||||
const filePath = '/files/abc123.txt';
|
||||
const expectedUrl = 'http://localhost:3000/files/abc123.txt';
|
||||
mockFileService.getFileHTTPURL.mockResolvedValue(expectedUrl);
|
||||
|
||||
const result = await controller.getFileHTTPURL(filePath);
|
||||
|
||||
expect(result).toBe(expectedUrl);
|
||||
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it('should handle get HTTP URL error', async () => {
|
||||
const filePath = '/files/abc123.txt';
|
||||
const error = new Error('Failed to generate URL');
|
||||
mockFileService.getFileHTTPURL.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getFileHTTPURL(filePath)).rejects.toThrow('Failed to generate URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
it('should delete files successfully', async () => {
|
||||
const paths = ['/files/file1.txt', '/files/file2.txt'];
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteFiles(paths);
|
||||
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(paths);
|
||||
});
|
||||
|
||||
it('should handle delete files error', async () => {
|
||||
const paths = ['/files/file1.txt'];
|
||||
const error = new Error('Delete failed');
|
||||
mockFileService.deleteFiles.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.deleteFiles(paths)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
|
||||
it('should handle empty paths array', async () => {
|
||||
const paths: string[] = [];
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteFiles(paths);
|
||||
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFile', () => {
|
||||
it('should create file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'xyz789',
|
||||
path: '/test/newfile.txt',
|
||||
content: 'bmV3IGZpbGUgY29udGVudA==',
|
||||
filename: 'newfile.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const expectedResult = { id: 'new-file-id', url: '/files/new-file-id' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.createFile(params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle create file error', async () => {
|
||||
const params = {
|
||||
hash: 'xyz789',
|
||||
path: '/test/newfile.txt',
|
||||
content: 'bmV3IGZpbGUgY29udGVudA==',
|
||||
filename: 'newfile.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const error = new Error('Create failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.createFile(params)).rejects.toThrow('Create failed');
|
||||
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class DevtoolsCtr extends ControllerModule {
|
||||
@ipcClientEvent('openDevtools')
|
||||
@IpcMethod()
|
||||
async openDevtools() {
|
||||
const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
|
||||
devtoolsBrowser.show();
|
||||
|
||||
@@ -1,34 +1,7 @@
|
||||
import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc';
|
||||
import type { ServerDispatchEvents } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IoCContainer } from '@/core/infrastructure/IoCContainer';
|
||||
import { ShortcutActionType } from '@/shortcuts';
|
||||
|
||||
const ipcDecorator =
|
||||
(name: string, mode: 'client' | 'server') =>
|
||||
(target: any, methodName: string, descriptor?: any) => {
|
||||
const actions = IoCContainer.controllers.get(target.constructor) || [];
|
||||
actions.push({
|
||||
methodName,
|
||||
mode,
|
||||
name,
|
||||
});
|
||||
IoCContainer.controllers.set(target.constructor, actions);
|
||||
return descriptor;
|
||||
};
|
||||
|
||||
/**
|
||||
* IPC client event decorator for controllers
|
||||
*/
|
||||
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
|
||||
ipcDecorator(method, 'client');
|
||||
|
||||
/**
|
||||
* IPC server event decorator for controllers
|
||||
*/
|
||||
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
|
||||
ipcDecorator(method, 'server');
|
||||
import { IpcService } from '@/utils/ipc';
|
||||
|
||||
const shortcutDecorator = (name: string) => (target: any, methodName: string, descriptor?: any) => {
|
||||
const actions = IoCContainer.shortcuts.get(target.constructor) || [];
|
||||
@@ -68,10 +41,13 @@ interface IControllerModule {
|
||||
beforeAppReady?(): void;
|
||||
}
|
||||
|
||||
export class ControllerModule implements IControllerModule {
|
||||
export class ControllerModule extends IpcService implements IControllerModule {
|
||||
constructor(public app: App) {
|
||||
super();
|
||||
this.app = app;
|
||||
}
|
||||
}
|
||||
|
||||
export type IControlModule = typeof ControllerModule;
|
||||
|
||||
export { IpcMethod, IpcServerMethod } from '@/utils/ipc';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } from '@/utils/ipc';
|
||||
|
||||
import AuthCtr from './AuthCtr';
|
||||
import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||
import DevtoolsCtr from './DevtoolsCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpInstallCtr from './McpInstallCtr';
|
||||
import MenuController from './MenuCtr';
|
||||
import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
import NotificationCtr from './NotificationCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import ShortcutController from './ShortcutCtr';
|
||||
import SystemController from './SystemCtr';
|
||||
import SystemServerCtr from './SystemServerCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
import UploadFileServerCtr from './UploadFileServerCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
AuthCtr,
|
||||
BrowserWindowsCtr,
|
||||
DevtoolsCtr,
|
||||
LocalFileCtr,
|
||||
McpInstallCtr,
|
||||
MenuController,
|
||||
NetworkProxyCtr,
|
||||
NotificationCtr,
|
||||
RemoteServerConfigCtr,
|
||||
RemoteServerSyncCtr,
|
||||
ShellCommandCtr,
|
||||
ShortcutController,
|
||||
SystemController,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
type DesktopControllerServices = CreateServicesResult<DesktopControllerIpcConstructors>;
|
||||
export type DesktopIpcServices = MergeIpcService<DesktopControllerServices>;
|
||||
|
||||
export const controllerServerIpcConstructors = [
|
||||
SystemServerCtr,
|
||||
UploadFileServerCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerServerConstructors = typeof controllerServerIpcConstructors;
|
||||
type DesktopServerControllerServices = CreateServicesResult<DesktopControllerServerConstructors>;
|
||||
export type DesktopServerIpcServices = MergeIpcService<DesktopServerControllerServices>;
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { Session, app, ipcMain, protocol } from 'electron';
|
||||
import { Session, app, protocol } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { buildDir, LOCAL_DATABASE_DIR, nextStandaloneDir } from '@/const/dir';
|
||||
import { LOCAL_DATABASE_DIR, buildDir, nextStandaloneDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import { getServerMethodMetadata } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
|
||||
|
||||
@@ -81,7 +81,7 @@ export class App {
|
||||
|
||||
// load controllers
|
||||
const controllers: IControlModule[] = importAll(
|
||||
(import.meta as any).glob('@/controllers/*Ctr.ts', { eager: true }),
|
||||
import.meta.glob('@/controllers/*Ctr.ts', { eager: true }),
|
||||
);
|
||||
|
||||
logger.debug(`Loading ${controllers.length} controllers`);
|
||||
@@ -89,13 +89,13 @@ export class App {
|
||||
|
||||
// load services
|
||||
const services: IServiceModule[] = importAll(
|
||||
(import.meta as any).glob('@/services/*Srv.ts', { eager: true }),
|
||||
import.meta.glob('@/services/*Srv.ts', { eager: true }),
|
||||
);
|
||||
|
||||
logger.debug(`Loading ${services.length} services`);
|
||||
services.forEach((service) => this.addService(service));
|
||||
|
||||
this.initializeIPCEvents();
|
||||
this.initializeServerIpcEvents();
|
||||
|
||||
this.i18n = new I18nManager(this);
|
||||
this.browserManager = new BrowserManager(this);
|
||||
@@ -268,10 +268,6 @@ export class App {
|
||||
private services = new Map<Class<any>, any>();
|
||||
|
||||
private ipcServer: ElectronIPCServer;
|
||||
/**
|
||||
* events dispatched from webview layer
|
||||
*/
|
||||
private ipcClientEventMap: IPCEventMap = new Map();
|
||||
private ipcServerEventMap: IPCEventMap = new Map();
|
||||
shortcutMethodMap: ShortcutMethodMap = new Map();
|
||||
protocolHandlerMap: ProtocolHandlerMap = new Map();
|
||||
@@ -327,22 +323,13 @@ export class App {
|
||||
const controller = new ControllerClass(this);
|
||||
this.controllers.set(ControllerClass, controller);
|
||||
|
||||
IoCContainer.controllers.get(ControllerClass)?.forEach((event) => {
|
||||
if (event.mode === 'client') {
|
||||
// Store all objects from event decorator in ipcClientEventMap
|
||||
this.ipcClientEventMap.set(event.name, {
|
||||
controller,
|
||||
methodName: event.methodName,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.mode === 'server') {
|
||||
// Store all objects from event decorator in ipcServerEventMap
|
||||
this.ipcServerEventMap.set(event.name, {
|
||||
controller,
|
||||
methodName: event.methodName,
|
||||
});
|
||||
}
|
||||
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) => {
|
||||
@@ -427,27 +414,8 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
private initializeIPCEvents() {
|
||||
logger.debug('Initializing IPC events');
|
||||
// Register batch controller client events for render side consumption
|
||||
this.ipcClientEventMap.forEach((eventInfo, key) => {
|
||||
const { controller, methodName } = eventInfo;
|
||||
|
||||
ipcMain.handle(key, async (e, data) => {
|
||||
// 从 WebContents 获取对应的 BrowserWindow id
|
||||
const senderIdentifier = this.browserManager.getIdentifierByWebContents(e.sender);
|
||||
try {
|
||||
return await controller[methodName](data, {
|
||||
identifier: senderIdentifier,
|
||||
} as IpcClientEventSender);
|
||||
} catch (error) {
|
||||
logger.error(`Error handling IPC event ${key}:`, error);
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Batch register server events from controllers for next server consumption
|
||||
private initializeServerIpcEvents() {
|
||||
logger.debug('Initializing IPC server events');
|
||||
const ipcServerEvents = {} as ElectronIPCEventHandler;
|
||||
|
||||
this.ipcServerEventMap.forEach((eventInfo, key) => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LOCAL_DATABASE_DIR } from '@/const/dir';
|
||||
|
||||
// Import after mocks are set up
|
||||
import { App } from '../App';
|
||||
|
||||
// Mock electron modules
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
@@ -24,6 +27,7 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
@@ -166,9 +170,6 @@ vi.mock('@/utils/next-electron-rsc', () => ({
|
||||
vi.mock('../../controllers/*Ctr.ts', () => ({}));
|
||||
vi.mock('../../services/*Srv.ts', () => ({}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { App } from '../App';
|
||||
|
||||
describe('App - Database Lock Cleanup', () => {
|
||||
let appInstance: App;
|
||||
let mockLockPath: string;
|
||||
@@ -177,7 +178,7 @@ describe('App - Database Lock Cleanup', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock glob imports to return empty arrays
|
||||
(import.meta as any).glob = vi.fn(() => ({}));
|
||||
import.meta.glob = vi.fn(() => ({}));
|
||||
|
||||
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
|
||||
});
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
* 存储应用中需要用装饰器的类
|
||||
*/
|
||||
export class IoCContainer {
|
||||
static controllers: WeakMap<
|
||||
any,
|
||||
{ methodName: string; mode: 'client' | 'server'; name: string }[]
|
||||
> = new WeakMap();
|
||||
|
||||
static shortcuts: WeakMap<any, { methodName: string; name: string }[]> = new WeakMap();
|
||||
|
||||
static protocolHandlers: WeakMap<any, { action: string; methodName: string; urlType: string }[]> =
|
||||
|
||||
@@ -13,52 +13,6 @@ describe('IoCContainer', () => {
|
||||
// For each test, use fresh class instances
|
||||
});
|
||||
|
||||
describe('controllers WeakMap', () => {
|
||||
it('should store controller metadata', () => {
|
||||
const metadata = [{ methodName: 'handleMessage', mode: 'client' as const, name: 'message' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should allow multiple controllers', () => {
|
||||
const metadata1 = [{ methodName: 'method1', mode: 'client' as const, name: 'action1' }];
|
||||
const metadata2 = [{ methodName: 'method2', mode: 'server' as const, name: 'action2' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata1);
|
||||
IoCContainer.controllers.set(AnotherController, metadata2);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata1);
|
||||
expect(IoCContainer.controllers.get(AnotherController)).toEqual(metadata2);
|
||||
});
|
||||
|
||||
it('should allow overwriting controller metadata', () => {
|
||||
const oldMetadata = [{ methodName: 'oldMethod', mode: 'client' as const, name: 'old' }];
|
||||
const newMetadata = [{ methodName: 'newMethod', mode: 'server' as const, name: 'new' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, oldMetadata);
|
||||
IoCContainer.controllers.set(TestController, newMetadata);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(newMetadata);
|
||||
});
|
||||
|
||||
it('should support multiple methods per controller', () => {
|
||||
const metadata = [
|
||||
{ methodName: 'method1', mode: 'client' as const, name: 'action1' },
|
||||
{ methodName: 'method2', mode: 'server' as const, name: 'action2' },
|
||||
{ methodName: 'method3', mode: 'client' as const, name: 'action3' },
|
||||
];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata);
|
||||
|
||||
const stored = IoCContainer.controllers.get(TestController);
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored?.[0].mode).toBe('client');
|
||||
expect(stored?.[1].mode).toBe('server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts WeakMap', () => {
|
||||
it('should store shortcut metadata', () => {
|
||||
const metadata = [{ methodName: 'toggleDarkMode', name: 'CmdOrCtrl+Shift+D' }];
|
||||
@@ -141,10 +95,6 @@ describe('IoCContainer', () => {
|
||||
});
|
||||
|
||||
describe('static properties', () => {
|
||||
it('should have controllers as a WeakMap', () => {
|
||||
expect(IoCContainer.controllers).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
|
||||
it('should have shortcuts as a WeakMap', () => {
|
||||
expect(IoCContainer.shortcuts).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import type { DesktopIpcServices } from './controllers/registry';
|
||||
|
||||
declare module '@lobechat/electron-client-ipc' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface DesktopIpcServicesMap extends DesktopIpcServices {}
|
||||
}
|
||||
|
||||
export { type DesktopIpcServices, type DesktopServerIpcServices } from './controllers/registry';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Export types for renderer/server to use
|
||||
export type { DesktopIpcServices, DesktopServerIpcServices } from './controllers/registry';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import 'vite/client';
|
||||
|
||||
export {};
|
||||
@@ -17,6 +17,12 @@ const repoRoot = path.resolve(__dirname, '../../../../..');
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integration', () => {
|
||||
const searchService = new MacOSSearchServiceImpl();
|
||||
const ensureResults = (results: unknown[], context: string) => {
|
||||
if (results.length > 0) return true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`⚠️ Spotlight returned 0 results for ${context} - indexing may be incomplete`);
|
||||
return false;
|
||||
};
|
||||
|
||||
describe('checkSearchServiceStatus', () => {
|
||||
it('should verify Spotlight is available on macOS', async () => {
|
||||
@@ -34,7 +40,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(results, 'package.json search')) return;
|
||||
|
||||
// Should find at least one package.json
|
||||
const packageJson = results.find((r) => r.name === 'package.json');
|
||||
@@ -49,7 +55,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(results, 'README search')) return;
|
||||
|
||||
// Should contain markdown files
|
||||
const mdFile = results.find((r) => r.type === 'md');
|
||||
@@ -64,7 +70,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(results, 'TypeScript file search')) return;
|
||||
|
||||
// Should find the macOS.ts implementation file
|
||||
const macOSFile = results.find((r) => r.name.includes('macOS') && r.type === 'ts');
|
||||
@@ -106,7 +112,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(results, 'test file search')) return;
|
||||
|
||||
// Should find test files (can be in __tests__ directory or co-located with source files)
|
||||
const testFile = results.find((r) => r.name.endsWith('.test.ts'));
|
||||
@@ -161,6 +167,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'TypeScript identification')) return;
|
||||
const tsFile = results.find((r) => r.name === 'LocalFileCtr.ts');
|
||||
if (tsFile) {
|
||||
expect(tsFile.type).toBe('ts');
|
||||
@@ -176,6 +183,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'JSON identification')) return;
|
||||
const jsonFile = results.find((r) => r.name.includes('tsconfig') && r.type === 'json');
|
||||
if (jsonFile) {
|
||||
expect(jsonFile.type).toBe('json');
|
||||
@@ -191,6 +199,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'directory identification')) return;
|
||||
const testDir = results.find((r) => r.name === '__tests__' && r.isDirectory);
|
||||
if (testDir) {
|
||||
expect(testDir.isDirectory).toBe(true);
|
||||
@@ -221,7 +230,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(results, 'file metadata read')) return;
|
||||
|
||||
const file = results[0];
|
||||
|
||||
@@ -279,7 +288,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(results, 'fuzzy search accuracy')) return;
|
||||
|
||||
// Should find LocalFileCtr.ts or similar files
|
||||
const found = results.some(
|
||||
@@ -319,8 +328,8 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
});
|
||||
|
||||
// Both searches should find similar files
|
||||
expect(lowerResults.length).toBeGreaterThan(0);
|
||||
expect(upperResults.length).toBeGreaterThan(0);
|
||||
if (!ensureResults(lowerResults, 'case-insensitive search (lower)')) return;
|
||||
if (!ensureResults(upperResults, 'case-insensitive search (upper)')) return;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@lobehub/desktop-ipc-typings",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./exports.d.ts",
|
||||
"types": "./exports.d.ts",
|
||||
"exports": {
|
||||
".": "./exports.d.ts"
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ interface UploadFileParams {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
export interface FileMetadata {
|
||||
date: string;
|
||||
dirname: string;
|
||||
filename: string;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface IpcClientEventSender {
|
||||
identifier: string;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { IpcContext } from '../base';
|
||||
import {
|
||||
IpcMethod,
|
||||
IpcServerMethod,
|
||||
IpcService,
|
||||
getIpcContext,
|
||||
getServerMethodMetadata,
|
||||
} from '../base';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ipc service base', () => {
|
||||
beforeEach(() => {
|
||||
ipcMainHandleMock.mockClear();
|
||||
});
|
||||
|
||||
it('registers handlers and forwards payload/context correctly', async () => {
|
||||
class TestService extends IpcService {
|
||||
static readonly groupName = 'test';
|
||||
public lastCall: { payload: string | undefined; context?: IpcContext } | null = null;
|
||||
|
||||
@IpcMethod()
|
||||
ping(payload?: string) {
|
||||
this.lastCall = { context: getIpcContext(), payload };
|
||||
return 'pong';
|
||||
}
|
||||
}
|
||||
|
||||
const service = new TestService();
|
||||
|
||||
expect(service).toBeTruthy();
|
||||
expect(ipcMainHandleMock).toHaveBeenCalledWith('test.ping', expect.any(Function));
|
||||
|
||||
const handler = ipcMainHandleMock.mock.calls[0][1];
|
||||
const fakeSender = { id: 1 } as any;
|
||||
const fakeEvent = { sender: fakeSender } as any;
|
||||
|
||||
const result = await handler(fakeEvent, 'hello');
|
||||
|
||||
expect(result).toBe('pong');
|
||||
expect(service.lastCall).toEqual({
|
||||
context: { event: fakeEvent, sender: fakeSender },
|
||||
payload: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows direct method invocation without IPC context', () => {
|
||||
class DirectCallService extends IpcService {
|
||||
static readonly groupName = 'direct';
|
||||
public invokedWith: string | null = null;
|
||||
|
||||
@IpcMethod()
|
||||
run(payload: string) {
|
||||
this.invokedWith = payload;
|
||||
return payload.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
const service = new DirectCallService();
|
||||
const result = service.run('test');
|
||||
|
||||
expect(result).toBe('TEST');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { IpcMainInvokeEvent, WebContents } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
// Base context for IPC methods
|
||||
export interface IpcContext {
|
||||
event: IpcMainInvokeEvent;
|
||||
sender: WebContents;
|
||||
}
|
||||
|
||||
// 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
|
||||
export function IpcMethod() {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const { constructor } = target;
|
||||
|
||||
if (!methodMetadata.has(constructor)) {
|
||||
methodMetadata.set(constructor, new Map());
|
||||
}
|
||||
|
||||
const methods = methodMetadata.get(constructor)!;
|
||||
methods.set(propertyKey, propertyKey);
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
private registeredChannels = new Set<string>();
|
||||
|
||||
static getInstance(): IpcHandler {
|
||||
if (!IpcHandler.instance) {
|
||||
IpcHandler.instance = new IpcHandler();
|
||||
}
|
||||
return IpcHandler.instance;
|
||||
}
|
||||
|
||||
registerMethod<TArgs extends unknown[], TOutput>(
|
||||
channel: string,
|
||||
handler: (...args: TArgs) => Promise<TOutput> | TOutput,
|
||||
) {
|
||||
if (this.registeredChannels.has(channel)) {
|
||||
return; // Already registered
|
||||
}
|
||||
|
||||
this.registeredChannels.add(channel);
|
||||
|
||||
ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: any[]) => {
|
||||
const context: IpcContext = {
|
||||
event,
|
||||
sender: event.sender,
|
||||
};
|
||||
|
||||
return ipcContextStorage.run(context, async () => {
|
||||
try {
|
||||
const typedArgs = args as TArgs;
|
||||
return await handler(...typedArgs);
|
||||
} catch (error) {
|
||||
console.error(`Error in IPC method ${channel}:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send events to renderer
|
||||
sendToRenderer<T = any>(webContents: WebContents, channel: string, data: T) {
|
||||
webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Base class for IPC service groups
|
||||
export abstract class IpcService {
|
||||
protected handler = IpcHandler.getInstance();
|
||||
static readonly groupName: string;
|
||||
|
||||
constructor() {
|
||||
this.registerMethods();
|
||||
}
|
||||
|
||||
protected registerMethods(): void {
|
||||
const { constructor } = this;
|
||||
const methods = methodMetadata.get(constructor);
|
||||
|
||||
if (methods) {
|
||||
methods.forEach((methodName, propertyKey) => {
|
||||
const method = (this as any)[propertyKey];
|
||||
if (typeof method === 'function') {
|
||||
this.registerMethod(methodName, method.bind(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected registerMethod<TArgs extends unknown[], TOutput>(
|
||||
methodName: string,
|
||||
handler: (...args: TArgs) => Promise<TOutput> | TOutput,
|
||||
) {
|
||||
const groupName = (this.constructor as typeof IpcService).groupName;
|
||||
const channel = `${groupName}.${methodName}`;
|
||||
this.handler.registerMethod(channel, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Service constructor with groupName
|
||||
export interface IpcServiceConstructor {
|
||||
new (...args: any[]): IpcService;
|
||||
readonly groupName: string;
|
||||
}
|
||||
|
||||
// Create services function that infers types from service constructors
|
||||
export function createServices<T extends readonly IpcServiceConstructor[]>(
|
||||
serviceConstructors: T,
|
||||
...constructorArgs: any[]
|
||||
): CreateServicesResult<T> {
|
||||
const services = {} as any;
|
||||
|
||||
for (const ServiceConstructor of serviceConstructors) {
|
||||
const instance = new ServiceConstructor(...constructorArgs);
|
||||
const groupName = ServiceConstructor.groupName;
|
||||
|
||||
if (!groupName) {
|
||||
throw new Error(
|
||||
`Service ${ServiceConstructor.name} must define a static readonly groupName property`,
|
||||
);
|
||||
}
|
||||
|
||||
services[groupName] = instance;
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Helper type for createServices return type
|
||||
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();
|
||||
}
|
||||
|
||||
export function runWithIpcContext<T>(context: IpcContext, callback: () => T): T {
|
||||
return ipcContextStorage.run(context, callback);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { CreateServicesResult, IpcContext, IpcServiceConstructor } from './base';
|
||||
export {
|
||||
createServices,
|
||||
getIpcContext,
|
||||
getServerMethodMetadata,
|
||||
IpcMethod,
|
||||
IpcServerMethod,
|
||||
IpcService,
|
||||
runWithIpcContext,
|
||||
} from './base';
|
||||
export type { ExtractServiceMethods, MergeIpcService } from './utility';
|
||||
@@ -0,0 +1,20 @@
|
||||
// Extract method signatures from service classes
|
||||
type ExtractMethodSignature<T> = T extends (...args: infer Args) => infer Output
|
||||
? (...args: Args) => AlwaysPromise<Output>
|
||||
: never;
|
||||
|
||||
export type ExtractServiceMethods<T> = {
|
||||
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: ExtractMethodSignature<T[K]>;
|
||||
};
|
||||
|
||||
type AlwaysPromise<T> = Promise<Awaited<T>>;
|
||||
|
||||
// TypeScript utility type to automatically merge IPC services
|
||||
// This version works with both the old object format and new createServices format
|
||||
export type MergeIpcService<T> = {
|
||||
[K in keyof T]: T[K] extends new (...args: any[]) => infer Instance
|
||||
? ExtractServiceMethods<Instance>
|
||||
: T[K] extends infer Instance
|
||||
? ExtractServiceMethods<Instance>
|
||||
: never;
|
||||
};
|
||||
@@ -15,5 +15,8 @@ export const setupElectronApi = () => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', { invoke, onStreamInvoke });
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
invoke,
|
||||
onStreamInvoke,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ClientDispatchEventKey } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock electron module
|
||||
@@ -21,9 +20,9 @@ describe('invoke', () => {
|
||||
const expectedResult = { success: true };
|
||||
mockIpcRendererInvoke.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await invoke('getAppVersion' as ClientDispatchEventKey);
|
||||
const result = await invoke('system.getAppVersion');
|
||||
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('getAppVersion');
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('system.getAppVersion');
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
@@ -33,9 +32,9 @@ describe('invoke', () => {
|
||||
const expectedResult = { navigated: true };
|
||||
mockIpcRendererInvoke.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await invoke('interceptRoute' as ClientDispatchEventKey, eventData);
|
||||
const result = await invoke('windows.interceptRoute', eventData);
|
||||
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('interceptRoute', eventData);
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('windows.interceptRoute', eventData);
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
@@ -59,16 +58,14 @@ describe('invoke', () => {
|
||||
const error = new Error('IPC communication failed');
|
||||
mockIpcRendererInvoke.mockRejectedValue(error);
|
||||
|
||||
await expect(invoke('getAppVersion' as ClientDispatchEventKey)).rejects.toThrow(
|
||||
'IPC communication failed',
|
||||
);
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('getAppVersion');
|
||||
await expect(invoke('system.getAppVersion')).rejects.toThrow('IPC communication failed');
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('system.getAppVersion');
|
||||
});
|
||||
|
||||
it('should handle ipcRenderer returning undefined', async () => {
|
||||
mockIpcRendererInvoke.mockResolvedValue(undefined);
|
||||
|
||||
const result = await invoke('someEvent' as ClientDispatchEventKey);
|
||||
const result = await invoke('someEvent');
|
||||
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('someEvent');
|
||||
expect(result).toBeUndefined();
|
||||
@@ -77,7 +74,7 @@ describe('invoke', () => {
|
||||
it('should handle ipcRenderer returning null', async () => {
|
||||
mockIpcRendererInvoke.mockResolvedValue(null);
|
||||
|
||||
const result = await invoke('someEvent' as ClientDispatchEventKey);
|
||||
const result = await invoke('someEvent');
|
||||
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('someEvent');
|
||||
expect(result).toBeNull();
|
||||
@@ -96,7 +93,7 @@ describe('invoke', () => {
|
||||
};
|
||||
mockIpcRendererInvoke.mockResolvedValue(complexData);
|
||||
|
||||
const result = await invoke('getData' as ClientDispatchEventKey);
|
||||
const result = await invoke('getData');
|
||||
|
||||
expect(result).toEqual(complexData);
|
||||
});
|
||||
@@ -125,9 +122,9 @@ describe('invoke', () => {
|
||||
.mockResolvedValueOnce({ id: 3 });
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([
|
||||
invoke('event1' as ClientDispatchEventKey),
|
||||
invoke('event2' as ClientDispatchEventKey),
|
||||
invoke('event3' as ClientDispatchEventKey),
|
||||
invoke('event1'),
|
||||
invoke('event2'),
|
||||
invoke('event3'),
|
||||
]);
|
||||
|
||||
expect(result1).toEqual({ id: 1 });
|
||||
@@ -139,7 +136,7 @@ describe('invoke', () => {
|
||||
it('should handle empty string as data parameter', async () => {
|
||||
mockIpcRendererInvoke.mockResolvedValue({ received: '' });
|
||||
|
||||
const result = await invoke('sendData' as ClientDispatchEventKey, '');
|
||||
const result = await invoke('sendData', '');
|
||||
|
||||
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('sendData', '');
|
||||
expect(result).toEqual({ received: '' });
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ClientDispatchEventKey, DispatchInvoke } from '@lobechat/electron-client-ipc';
|
||||
import { DispatchInvoke } from '@lobechat/electron-client-ipc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
/**
|
||||
* Client-side method to invoke electron main process
|
||||
*/
|
||||
export const invoke: DispatchInvoke = async <T extends ClientDispatchEventKey>(
|
||||
event: T,
|
||||
...data: any[]
|
||||
) => ipcRenderer.invoke(event, ...data);
|
||||
export const invoke: DispatchInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('setupRouteInterceptors', () => {
|
||||
const externalUrl = 'https://google.com';
|
||||
const result = window.open(externalUrl, '_blank');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('openExternalLink', externalUrl);
|
||||
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', externalUrl);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('setupRouteInterceptors', () => {
|
||||
const externalUrl = new URL('https://github.com');
|
||||
const result = window.open(externalUrl, '_blank');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('openExternalLink', 'https://github.com/');
|
||||
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', 'https://github.com/');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('setupRouteInterceptors', () => {
|
||||
// We can't fully test the original behavior in happy-dom, but we can verify invoke is not called
|
||||
window.open(internalUrl);
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith('openExternalLink', expect.anything());
|
||||
expect(invoke).not.toHaveBeenCalledWith('system.openExternalLink', expect.anything());
|
||||
});
|
||||
|
||||
it('should handle relative URL that resolves as internal link', () => {
|
||||
@@ -81,7 +81,7 @@ describe('setupRouteInterceptors', () => {
|
||||
window.open(relativeUrl);
|
||||
|
||||
// Since it's internal, it won't call invoke for external link
|
||||
expect(invoke).not.toHaveBeenCalledWith('openExternalLink', expect.anything());
|
||||
expect(invoke).not.toHaveBeenCalledWith('system.openExternalLink', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('setupRouteInterceptors', () => {
|
||||
// Wait for async handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('openExternalLink', 'https://example.com/');
|
||||
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', 'https://example.com/');
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('setupRouteInterceptors', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith('/desktop/devtools');
|
||||
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
||||
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
||||
path: '/desktop/devtools',
|
||||
source: 'link-click',
|
||||
url: 'http://localhost:3000/desktop/devtools',
|
||||
@@ -166,7 +166,7 @@ describe('setupRouteInterceptors', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
expect(invoke).not.toHaveBeenCalledWith('interceptRoute', expect.anything());
|
||||
expect(invoke).not.toHaveBeenCalledWith('windows.interceptRoute', expect.anything());
|
||||
});
|
||||
|
||||
it('should handle non-HTTP link protocols as external links', async () => {
|
||||
@@ -184,7 +184,7 @@ describe('setupRouteInterceptors', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// mailto: links are treated as external links by the URL constructor
|
||||
expect(invoke).toHaveBeenCalledWith('openExternalLink', 'mailto:test@example.com');
|
||||
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', 'mailto:test@example.com');
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe('setupRouteInterceptors', () => {
|
||||
history.pushState({}, '', '/desktop/devtools');
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith('/desktop/devtools');
|
||||
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
||||
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
||||
path: '/desktop/devtools',
|
||||
source: 'push-state',
|
||||
url: 'http://localhost:3000/desktop/devtools',
|
||||
@@ -245,7 +245,7 @@ describe('setupRouteInterceptors', () => {
|
||||
|
||||
history.pushState({}, '', '/chat/new');
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith('interceptRoute', expect.anything());
|
||||
expect(invoke).not.toHaveBeenCalledWith('windows.interceptRoute', expect.anything());
|
||||
});
|
||||
|
||||
it('should handle pushState errors gracefully', () => {
|
||||
@@ -279,7 +279,7 @@ describe('setupRouteInterceptors', () => {
|
||||
history.replaceState({}, '', '/desktop/devtools');
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith('/desktop/devtools');
|
||||
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
||||
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
||||
path: '/desktop/devtools',
|
||||
source: 'replace-state',
|
||||
url: 'http://localhost:3000/desktop/devtools',
|
||||
@@ -317,7 +317,7 @@ describe('setupRouteInterceptors', () => {
|
||||
|
||||
history.replaceState({}, '', '/chat/session-123');
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith('interceptRoute', expect.anything());
|
||||
expect(invoke).not.toHaveBeenCalledWith('windows.interceptRoute', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -385,7 +385,7 @@ describe('setupRouteInterceptors', () => {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
||||
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
||||
path: '/desktop/devtools',
|
||||
source: 'push-state',
|
||||
url: 'http://localhost:3000/desktop/devtools',
|
||||
|
||||
@@ -11,7 +11,7 @@ const interceptRoute = async (
|
||||
|
||||
// Use electron-client-ipc's dispatch method
|
||||
try {
|
||||
await invoke('interceptRoute', { path, source, url });
|
||||
await invoke('windows.interceptRoute', { path, source, url });
|
||||
} catch (e) {
|
||||
console.error(`[preload] Route interception (${source}) call failed`, e);
|
||||
}
|
||||
@@ -37,14 +37,14 @@ export const setupRouteInterceptors = function () {
|
||||
if (urlObj.origin !== window.location.origin) {
|
||||
console.log(`[preload] Intercepted window.open for external URL:`, urlString);
|
||||
// Call main process to handle external link
|
||||
invoke('openExternalLink', urlString);
|
||||
invoke('system.openExternalLink', urlString);
|
||||
return null; // Return null to indicate no window was opened
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle invalid URL or special protocol
|
||||
console.error(`[preload] Intercepted window.open for special protocol:`, url);
|
||||
console.error(error);
|
||||
invoke('openExternalLink', typeof url === 'string' ? url : url.toString());
|
||||
invoke('system.openExternalLink', typeof url === 'string' ? url : url.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export const setupRouteInterceptors = function () {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Call main process to handle external link
|
||||
await invoke('openExternalLink', url.href);
|
||||
await invoke('system.openExternalLink', url.href);
|
||||
return false; // Explicitly prevent subsequent processing
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,28 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"target": "ESNext",
|
||||
"emitDeclarationOnly": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"experimentalDecorators": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["src/main/*"],
|
||||
"~common/*": ["src/common/*"]
|
||||
"@/*": [
|
||||
"src/main/*"
|
||||
],
|
||||
"~common/*": [
|
||||
"src/common/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src/main/**/*", "src/preload/**/*", "electron-builder.js"]
|
||||
}
|
||||
"include": [
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"electron-builder.js"
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -27,7 +27,8 @@
|
||||
"sideEffects": false,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"e2e"
|
||||
"e2e",
|
||||
"apps/desktop/src/main"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
|
||||
@@ -169,6 +170,7 @@
|
||||
"@lobehub/charts": "^2.1.2",
|
||||
"@lobehub/chat-plugin-sdk": "^1.32.4",
|
||||
"@lobehub/chat-plugins-gateway": "^1.9.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^1.23.1",
|
||||
"@lobehub/icons": "^2.43.1",
|
||||
"@lobehub/market-sdk": "^0.23.2",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { DispatchInvoke, type ProxyTRPCRequestParams } from './types';
|
||||
|
||||
interface StreamerCallbacks {
|
||||
onData: (chunk: Uint8Array) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: Error) => void;
|
||||
onResponse: (response: {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface IElectronAPI {
|
||||
invoke: DispatchInvoke;
|
||||
onStreamInvoke: (params: ProxyTRPCRequestParams, callbacks: StreamerCallbacks) => () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: IElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* client 端请求 main 端 event 数据的方法
|
||||
*/
|
||||
export const dispatch: DispatchInvoke = async (event, ...data) => {
|
||||
if (!window.electronAPI || !window.electronAPI.invoke)
|
||||
throw new Error(`electronAPI.invoke not found. Please expose \`ipcRenderer.invoke\` to \`window.electronAPI.invoke\` in the preload:
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
const invoke = async (event, ...data) =>
|
||||
ipcRenderer.invoke(event, ...data);
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', { invoke });
|
||||
`);
|
||||
|
||||
return window.electronAPI.invoke(event, ...data);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './dispatch';
|
||||
export * from './events';
|
||||
export * from './ipc';
|
||||
export * from './streamInvoke';
|
||||
export * from './types';
|
||||
export * from './useWatchBroadcast';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const restoreWindow = (() => {
|
||||
const originalWindow = (globalThis as any).window;
|
||||
|
||||
return () => {
|
||||
if (originalWindow === undefined) {
|
||||
Reflect.deleteProperty(globalThis as any, 'window');
|
||||
return;
|
||||
}
|
||||
(globalThis as any).window = originalWindow;
|
||||
};
|
||||
})();
|
||||
|
||||
describe('getElectronIpc', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
restoreWindow();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreWindow();
|
||||
});
|
||||
|
||||
it('returns null when window is not defined', async () => {
|
||||
Reflect.deleteProperty(globalThis as any, 'window');
|
||||
const { getElectronIpc } = await import('./ipc');
|
||||
|
||||
expect(getElectronIpc()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when invoke is missing on electronAPI', async () => {
|
||||
(globalThis as any).window = { electronAPI: {} };
|
||||
const { getElectronIpc } = await import('./ipc');
|
||||
|
||||
expect(getElectronIpc()).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a cached proxy and forwards payloads to invoke', async () => {
|
||||
const invoke = vi.fn();
|
||||
(globalThis as any).window = {
|
||||
electronAPI: {
|
||||
invoke,
|
||||
onStreamInvoke: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const { getElectronIpc } = await import('./ipc');
|
||||
|
||||
const ipc = getElectronIpc();
|
||||
expect(ipc).not.toBeNull();
|
||||
|
||||
await (ipc as any).system.updateLocale('en-US');
|
||||
expect(invoke).toHaveBeenCalledWith('system.updateLocale', 'en-US');
|
||||
|
||||
await (ipc as any).windows.closeWindow();
|
||||
expect(invoke).toHaveBeenCalledWith('windows.closeWindow');
|
||||
|
||||
const cached = getElectronIpc();
|
||||
expect(cached).toBe(ipc);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||
import type { DispatchInvoke } from './types/dispatch';
|
||||
import type { ProxyTRPCRequestParams } from './types/proxyTRPCRequest';
|
||||
|
||||
interface StreamerCallbacks {
|
||||
onData: (chunk: Uint8Array) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: Error) => void;
|
||||
onResponse: (response: {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface DesktopIpcServicesMap {}
|
||||
export type DesktopIpcServices = DesktopIpcServicesMap;
|
||||
export type ElectronDesktopIpc = DesktopIpcServices | null;
|
||||
|
||||
const createInvokeProxy = <IpcServices>(invoke: DispatchInvoke): 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;
|
||||
|
||||
let cachedProxy: DesktopIpcServices | null = null;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
invoke?: DispatchInvoke;
|
||||
onStreamInvoke: (params: ProxyTRPCRequestParams, callbacks: StreamerCallbacks) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getElectronIpc = (): DesktopIpcServices | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (cachedProxy) return cachedProxy;
|
||||
|
||||
const invoke = window.electronAPI?.invoke;
|
||||
if (!invoke) return null;
|
||||
|
||||
cachedProxy = createInvokeProxy<DesktopIpcServices>(invoke);
|
||||
return cachedProxy;
|
||||
};
|
||||
@@ -34,7 +34,13 @@ export const streamInvoke = async (input: RequestInfo | URL, init?: RequestInit)
|
||||
},
|
||||
});
|
||||
|
||||
const cleanup = window.electronAPI.onStreamInvoke(params, {
|
||||
const electronAPI = window.electronAPI;
|
||||
if (!electronAPI || !electronAPI.onStreamInvoke) {
|
||||
reject(new Error('[streamInvoke] window.electronAPI.onStreamInvoke is not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = electronAPI.onStreamInvoke(params, {
|
||||
onData: (chunk) => {
|
||||
if (streamController) streamController.enqueue(chunk);
|
||||
},
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
import type {
|
||||
ClientDispatchEventKey,
|
||||
ClientDispatchEvents,
|
||||
ClientEventReturnType,
|
||||
} from '../events';
|
||||
|
||||
export type DispatchInvoke = <T extends ClientDispatchEventKey>(
|
||||
event: T,
|
||||
...data: Parameters<ClientDispatchEvents[T]>
|
||||
) => Promise<ClientEventReturnType<T>>;
|
||||
export type DispatchInvoke = <T = unknown>(event: string, ...data: any[]) => Promise<T>;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'lcov', 'text-summary'],
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const';
|
||||
import { ServerDispatchEventKey } from './events';
|
||||
|
||||
const log = debug('electron-server-ipc:client');
|
||||
|
||||
@@ -178,7 +177,7 @@ export class ElectronIpcClient {
|
||||
}
|
||||
|
||||
// Send request to Electron IPC server
|
||||
public async sendRequest<T>(method: ServerDispatchEventKey, params: any = {}): Promise<T> {
|
||||
public async sendRequest<T>(method: string, params: any = {}): Promise<T> {
|
||||
if (!this.socketPath) {
|
||||
console.error('Cannot send request: Electron IPC connection not available');
|
||||
throw new Error('Electron IPC connection not available');
|
||||
|
||||
@@ -5,7 +5,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const';
|
||||
import { ServerDispatchEventKey } from './events';
|
||||
import { ElectronIPCEventHandler } from './types';
|
||||
|
||||
const log = debug('electron-server-ipc:server');
|
||||
@@ -107,7 +106,7 @@ export class ElectronIPCServer {
|
||||
log('Handling request: %s (ID: %s)', method, id);
|
||||
|
||||
// Execute corresponding operation based on request method
|
||||
const eventHandler = this.eventHandler[method as ServerDispatchEventKey];
|
||||
const eventHandler = this.eventHandler[method];
|
||||
if (!eventHandler) {
|
||||
console.error('No handler found for method: %s', method);
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import net from 'node:net';
|
||||
|
||||
import { ServerDispatchEventKey } from '../events';
|
||||
|
||||
export type IPCEventMethod = (
|
||||
params: any,
|
||||
context: { id: string; method: string; socket: net.Socket },
|
||||
) => Promise<any>;
|
||||
|
||||
export type ElectronIPCEventHandler = {
|
||||
[key in ServerDispatchEventKey]: IPCEventMethod;
|
||||
};
|
||||
export type ElectronIPCEventHandler = Record<string, IPCEventMethod>;
|
||||
|
||||
export * from './file';
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ packages:
|
||||
- 'packages/**'
|
||||
- '.'
|
||||
- 'e2e'
|
||||
- '!apps/**'
|
||||
- 'apps/desktop/src/main'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import i18nConfig from '../../.i18nrc';
|
||||
import i18nConfig from './i18nConfig';
|
||||
|
||||
export const root = resolve(__dirname, '../..');
|
||||
export const localesDir = resolve(root, i18nConfig.output);
|
||||
@@ -15,4 +15,4 @@ export const outputLocaleJsonFilepath = (locale: string, file: string) =>
|
||||
resolve(localesDir, locale, file);
|
||||
export const srcDefaultLocales = resolve(root, srcLocalesDir, 'default');
|
||||
|
||||
export { default as i18nConfig } from '../../.i18nrc';
|
||||
export { default as i18nConfig } from './i18nConfig';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const config = require('../../.i18nrc');
|
||||
|
||||
export default config;
|
||||
@@ -3,7 +3,7 @@ import { colors } from 'consola/utils';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import prettier from "@prettier/sync";
|
||||
import i18nConfig from '../../.i18nrc';
|
||||
import i18nConfig from './i18nConfig';
|
||||
|
||||
let prettierOptions = prettier.resolveConfig(
|
||||
resolve(__dirname, '../../.prettierrc.js')
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ const ProviderConfig = memo(() => {
|
||||
const tab = 'provider';
|
||||
|
||||
if (isDesktop) {
|
||||
const { dispatch } = await import('@lobechat/electron-client-ipc');
|
||||
await dispatch('openSettingsWindow', {
|
||||
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
|
||||
await ensureElectronIpc().windows.openSettingsWindow({
|
||||
searchParams,
|
||||
tab,
|
||||
});
|
||||
|
||||
@@ -806,6 +806,7 @@ export default {
|
||||
noEnabled: '暂无启用插件',
|
||||
store: '插件商店',
|
||||
},
|
||||
|
||||
tabs: {
|
||||
all: '全部',
|
||||
installed: '已启用',
|
||||
|
||||
@@ -1,46 +1,92 @@
|
||||
import { CreateFileParams, ElectronIpcClient, 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.sendRequest<string>('getDatabasePath');
|
||||
return this.ipc.system.getDatabasePath();
|
||||
};
|
||||
|
||||
// 获取用户数据路径
|
||||
getUserDataPath = async (): Promise<string> => {
|
||||
return this.sendRequest<string>('getUserDataPath');
|
||||
return this.ipc.system.getUserDataPath();
|
||||
};
|
||||
|
||||
getDatabaseSchemaHash = async () => {
|
||||
return this.sendRequest<string>('setDatabaseSchemaHash');
|
||||
return this.ipc.system.getDatabaseSchemaHash();
|
||||
};
|
||||
|
||||
setDatabaseSchemaHash = async (hash: string | undefined) => {
|
||||
if (!hash) return;
|
||||
|
||||
return this.sendRequest('setDatabaseSchemaHash', hash);
|
||||
return this.ipc.system.setDatabaseSchemaHash(hash);
|
||||
};
|
||||
|
||||
getFilePathById = async (id: string) => {
|
||||
return this.sendRequest<string>('getStaticFilePath', id);
|
||||
return this.ipc.upload.getFileUrlById(id);
|
||||
};
|
||||
|
||||
getFileHTTPURL = async (path: string) => {
|
||||
return this.sendRequest<string>('getFileHTTPURL', path);
|
||||
return this.ipc.upload.getFileHTTPURL(path);
|
||||
};
|
||||
|
||||
deleteFiles = async (paths: string[]) => {
|
||||
return this.sendRequest<{ errors?: { message: string; path: string }[]; success: boolean }>(
|
||||
'deleteFiles',
|
||||
paths,
|
||||
);
|
||||
return this.ipc.upload.deleteFiles(paths);
|
||||
};
|
||||
|
||||
createFile = async (params: CreateFileParams) => {
|
||||
return this.sendRequest<{ metadata: FileMetadata; success: boolean }>('createFile', params);
|
||||
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;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { electronDevtoolsService } from '../devtools';
|
||||
|
||||
vi.mock('@lobechat/electron-client-ipc', () => ({
|
||||
dispatch: vi.fn(),
|
||||
const openDevtoolsMock = vi.fn();
|
||||
vi.mock('@/utils/electron/ipc', () => ({
|
||||
ensureElectronIpc: vi.fn(() => ({
|
||||
devtools: { openDevtools: openDevtoolsMock },
|
||||
})),
|
||||
}));
|
||||
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
|
||||
|
||||
describe('DevtoolsService', () => {
|
||||
beforeEach(() => {
|
||||
@@ -15,18 +18,19 @@ describe('DevtoolsService', () => {
|
||||
describe('openDevtools', () => {
|
||||
it('should call dispatch with openDevtools', async () => {
|
||||
await electronDevtoolsService.openDevtools();
|
||||
expect(dispatch).toHaveBeenCalledWith('openDevtools');
|
||||
expect(ensureElectronIpc).toHaveBeenCalled();
|
||||
expect(openDevtoolsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return void when dispatch succeeds', async () => {
|
||||
vi.mocked(dispatch).mockResolvedValueOnce();
|
||||
openDevtoolsMock.mockResolvedValueOnce(undefined);
|
||||
const result = await electronDevtoolsService.openDevtools();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when dispatch fails', async () => {
|
||||
const error = new Error('Failed to open devtools');
|
||||
vi.mocked(dispatch).mockRejectedValueOnce(error);
|
||||
openDevtoolsMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(electronDevtoolsService.openDevtools()).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
class AutoUpdateService {
|
||||
checkUpdate = async () => {
|
||||
return dispatch('checkUpdate');
|
||||
return ensureElectronIpc().autoUpdate.checkForUpdates();
|
||||
};
|
||||
|
||||
installNow = async () => {
|
||||
return dispatch('installNow');
|
||||
return ensureElectronIpc().autoUpdate.quitAndInstallUpdate();
|
||||
};
|
||||
|
||||
installLater = async () => {
|
||||
return dispatch('installLater');
|
||||
return ensureElectronIpc().autoUpdate.installLater();
|
||||
};
|
||||
|
||||
downloadUpdate() {
|
||||
return dispatch('downloadUpdate');
|
||||
return ensureElectronIpc().autoUpdate.downloadUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
dispatch,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { DesktopNotificationResult, ShowDesktopNotificationParams } from '@lobechat/electron-client-ipc';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
/**
|
||||
* Desktop notification service
|
||||
@@ -16,7 +13,7 @@ export class DesktopNotificationService {
|
||||
async showNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
return dispatch('showDesktopNotification', params);
|
||||
return ensureElectronIpc().notification.showDesktopNotification(params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +21,7 @@ export class DesktopNotificationService {
|
||||
* @returns Whether it is hidden
|
||||
*/
|
||||
async isMainWindowHidden(): Promise<boolean> {
|
||||
return dispatch('isMainWindowHidden');
|
||||
return ensureElectronIpc().notification.isMainWindowHidden();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
class DevtoolsService {
|
||||
async openDevtools(): Promise<void> {
|
||||
return dispatch('openDevtools');
|
||||
return ensureElectronIpc().devtools.openDevtools();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { FileMetadata } from '@lobechat/types';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
/**
|
||||
* Desktop application file API client service
|
||||
*/
|
||||
@@ -19,7 +20,7 @@ class DesktopFileAPI {
|
||||
): Promise<{ metadata: FileMetadata; success: boolean }> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
return dispatch('createFile', {
|
||||
return ensureElectronIpc().upload.uploadFile({
|
||||
content: arrayBuffer,
|
||||
filename: file.name,
|
||||
hash,
|
||||
|
||||
@@ -23,71 +23,72 @@ import {
|
||||
RunCommandParams,
|
||||
RunCommandResult,
|
||||
WriteLocalFileParams,
|
||||
dispatch,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
class LocalFileService {
|
||||
// File Operations
|
||||
async listLocalFiles(params: ListLocalFileParams): Promise<LocalFileItem[]> {
|
||||
return dispatch('listLocalFiles', params);
|
||||
return ensureElectronIpc().localSystem.listLocalFiles(params);
|
||||
}
|
||||
|
||||
async readLocalFile(params: LocalReadFileParams): Promise<LocalReadFileResult> {
|
||||
return dispatch('readLocalFile', params);
|
||||
return ensureElectronIpc().localSystem.readFile(params);
|
||||
}
|
||||
|
||||
async readLocalFiles(params: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
|
||||
return dispatch('readLocalFiles', params);
|
||||
return ensureElectronIpc().localSystem.readFiles(params);
|
||||
}
|
||||
|
||||
async searchLocalFiles(params: LocalSearchFilesParams): Promise<LocalFileItem[]> {
|
||||
return dispatch('searchLocalFiles', params);
|
||||
return ensureElectronIpc().localSystem.handleLocalFilesSearch(params);
|
||||
}
|
||||
|
||||
async openLocalFile(params: OpenLocalFileParams) {
|
||||
return dispatch('openLocalFile', params);
|
||||
return ensureElectronIpc().localSystem.handleOpenLocalFile(params);
|
||||
}
|
||||
|
||||
async openLocalFolder(params: OpenLocalFolderParams) {
|
||||
return dispatch('openLocalFolder', params);
|
||||
return ensureElectronIpc().localSystem.handleOpenLocalFile(params);
|
||||
}
|
||||
|
||||
async moveLocalFiles(params: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
|
||||
return dispatch('moveLocalFiles', params);
|
||||
return ensureElectronIpc().localSystem.handleMoveFiles(params);
|
||||
}
|
||||
|
||||
async renameLocalFile(params: RenameLocalFileParams) {
|
||||
return dispatch('renameLocalFile', params);
|
||||
return ensureElectronIpc().localSystem.handleRenameFile(params);
|
||||
}
|
||||
|
||||
async writeFile(params: WriteLocalFileParams) {
|
||||
return dispatch('writeLocalFile', params);
|
||||
return ensureElectronIpc().localSystem.handleWriteFile(params);
|
||||
}
|
||||
|
||||
async editLocalFile(params: EditLocalFileParams): Promise<EditLocalFileResult> {
|
||||
return dispatch('editLocalFile', params);
|
||||
return ensureElectronIpc().localSystem.handleEditFile(params);
|
||||
}
|
||||
|
||||
// Shell Commands
|
||||
async runCommand(params: RunCommandParams): Promise<RunCommandResult> {
|
||||
return dispatch('runCommand', params);
|
||||
return ensureElectronIpc().shellCommand.handleRunCommand(params);
|
||||
}
|
||||
|
||||
async getCommandOutput(params: GetCommandOutputParams): Promise<GetCommandOutputResult> {
|
||||
return dispatch('getCommandOutput', params);
|
||||
return ensureElectronIpc().shellCommand.handleGetCommandOutput(params);
|
||||
}
|
||||
|
||||
async killCommand(params: KillCommandParams): Promise<KillCommandResult> {
|
||||
return dispatch('killCommand', params);
|
||||
return ensureElectronIpc().shellCommand.handleKillCommand(params);
|
||||
}
|
||||
|
||||
// Search & Find
|
||||
async grepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return dispatch('grepContent', params);
|
||||
return ensureElectronIpc().localSystem.handleGrepContent(params);
|
||||
}
|
||||
|
||||
async globFiles(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return dispatch('globLocalFiles', params);
|
||||
return ensureElectronIpc().localSystem.handleGlobFiles(params);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { DataSyncConfig, MarketAuthorizationParams, dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { DataSyncConfig, MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
class RemoteServerService {
|
||||
/**
|
||||
* Get remote server configuration
|
||||
*/
|
||||
getRemoteServerConfig = async () => {
|
||||
return dispatch('getRemoteServerConfig');
|
||||
return ensureElectronIpc().remoteServer.getRemoteServerConfig();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set remote server configuration
|
||||
*/
|
||||
setRemoteServerConfig = async (config: DataSyncConfig) => {
|
||||
return dispatch('setRemoteServerConfig', config);
|
||||
return ensureElectronIpc().remoteServer.setRemoteServerConfig(config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear remote server configuration
|
||||
*/
|
||||
clearRemoteServerConfig = async () => {
|
||||
return dispatch('clearRemoteServerConfig');
|
||||
return ensureElectronIpc().remoteServer.clearRemoteServerConfig();
|
||||
};
|
||||
|
||||
/**
|
||||
* Request authorization
|
||||
*/
|
||||
requestAuthorization = async (config: DataSyncConfig) => {
|
||||
return dispatch('requestAuthorization', config);
|
||||
return ensureElectronIpc().auth.requestAuthorization(config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Request Market authorization
|
||||
*/
|
||||
requestMarketAuthorization = async (params: MarketAuthorizationParams) => {
|
||||
return dispatch('requestMarketAuthorization', params);
|
||||
return ensureElectronIpc().auth.requestMarketAuthorization(params);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
import {
|
||||
NetworkProxySettings,
|
||||
ShortcutUpdateResult,
|
||||
dispatch,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { NetworkProxySettings, ShortcutUpdateResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
class DesktopSettingsService {
|
||||
/**
|
||||
* Get proxy settings
|
||||
*/
|
||||
getProxySettings = async () => {
|
||||
return dispatch('getProxySettings');
|
||||
return ensureElectronIpc().networkProxy.getDesktopSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set proxy settings
|
||||
*/
|
||||
setSettings = async (data: Partial<NetworkProxySettings>) => {
|
||||
return dispatch('setProxySettings', data);
|
||||
return ensureElectronIpc().networkProxy.setProxySettings(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get desktop hotkey configuration
|
||||
*/
|
||||
getDesktopHotkeys = async () => {
|
||||
return dispatch('getShortcutsConfig');
|
||||
return ensureElectronIpc().shortcut.getShortcutsConfig();
|
||||
};
|
||||
|
||||
/**
|
||||
* Update desktop hotkey configuration
|
||||
*/
|
||||
updateDesktopHotkey = async (id: string, accelerator: string): Promise<ShortcutUpdateResult> => {
|
||||
return dispatch('updateShortcutConfig', { accelerator, id });
|
||||
return ensureElectronIpc().shortcut.updateShortcutConfig({ accelerator, id });
|
||||
};
|
||||
|
||||
/**
|
||||
* Test proxy connection
|
||||
*/
|
||||
testProxyConnection = async (url: string) => {
|
||||
return dispatch('testProxyConnection', url);
|
||||
return ensureElectronIpc().networkProxy.testProxyConnection(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Test specified proxy configuration
|
||||
*/
|
||||
testProxyConfig = async (config: NetworkProxySettings, testUrl?: string) => {
|
||||
return dispatch('testProxyConfig', { config, testUrl });
|
||||
return ensureElectronIpc().networkProxy.testProxyConfig({ config, testUrl });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ElectronAppState, dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { ElectronAppState } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
/**
|
||||
* Service class for interacting with Electron's system-level information and actions.
|
||||
@@ -11,23 +13,23 @@ class ElectronSystemService {
|
||||
*/
|
||||
async getAppState(): Promise<ElectronAppState> {
|
||||
// Calls the underlying IPC function to get data from the main process
|
||||
return dispatch('getDesktopAppState');
|
||||
return ensureElectronIpc().system.getAppState();
|
||||
}
|
||||
|
||||
async closeWindow(): Promise<void> {
|
||||
return dispatch('closeWindow');
|
||||
return ensureElectronIpc().windows.closeWindow();
|
||||
}
|
||||
|
||||
async maximizeWindow(): Promise<void> {
|
||||
return dispatch('maximizeWindow');
|
||||
return ensureElectronIpc().windows.maximizeWindow();
|
||||
}
|
||||
|
||||
async minimizeWindow(): Promise<void> {
|
||||
return dispatch('minimizeWindow');
|
||||
return ensureElectronIpc().windows.minimizeWindow();
|
||||
}
|
||||
|
||||
showContextMenu = async (type: string, data?: any) => {
|
||||
return dispatch('showContextMenu', { data, type });
|
||||
return ensureElectronIpc().menu.showContextMenu({ data, type });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export const pluginTypes: StateCreator<
|
||||
|
||||
let data: MCPToolCallResult | undefined;
|
||||
|
||||
// Get message to extract sessionId/topicId
|
||||
// Get message to extract agentId/topicId
|
||||
const message = dbMessageSelectors.getDbMessageById(id)(get());
|
||||
|
||||
// Get abort controller from operation
|
||||
|
||||
@@ -39,11 +39,10 @@ export const generalActionSlice: StateCreator<
|
||||
if (!isDesktop) return;
|
||||
|
||||
try {
|
||||
const { dispatch } = await import('@lobechat/electron-client-ipc');
|
||||
|
||||
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
|
||||
const url = `/chat?session=${sessionId}&mode=single`;
|
||||
|
||||
const result = await dispatch('createMultiInstanceWindow', {
|
||||
const result = await ensureElectronIpc().windows.createMultiInstanceWindow({
|
||||
path: url,
|
||||
templateId: 'chatSingle',
|
||||
uniqueId: `chat_${sessionId}`,
|
||||
@@ -61,11 +60,10 @@ export const generalActionSlice: StateCreator<
|
||||
if (!isDesktop) return;
|
||||
|
||||
try {
|
||||
const { dispatch } = await import('@lobechat/electron-client-ipc');
|
||||
|
||||
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
|
||||
const url = `/chat?session=${sessionId}&topic=${topicId}&mode=single`;
|
||||
|
||||
const result = await dispatch('createMultiInstanceWindow', {
|
||||
const result = await ensureElectronIpc().windows.createMultiInstanceWindow({
|
||||
path: url,
|
||||
templateId: 'chatSingle',
|
||||
uniqueId: `chat_${sessionId}_${topicId}`,
|
||||
@@ -87,9 +85,9 @@ export const generalActionSlice: StateCreator<
|
||||
if (isDesktop && !skipBroadcast) {
|
||||
(async () => {
|
||||
try {
|
||||
const { dispatch } = await import('@lobechat/electron-client-ipc');
|
||||
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
|
||||
|
||||
await dispatch('updateLocale', locale);
|
||||
await ensureElectronIpc().system.updateLocale(locale);
|
||||
} catch (error) {
|
||||
console.error('Failed to update locale in main process:', error);
|
||||
}
|
||||
@@ -104,8 +102,8 @@ export const generalActionSlice: StateCreator<
|
||||
if (isDesktop && !skipBroadcast) {
|
||||
(async () => {
|
||||
try {
|
||||
const { dispatch } = await import('@lobechat/electron-client-ipc');
|
||||
await dispatch('updateThemeMode', themeMode);
|
||||
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
|
||||
await ensureElectronIpc().system.updateThemeModeHandler(themeMode);
|
||||
} catch (error) {
|
||||
console.error('Failed to update theme in main process:', error);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { ProxyTRPCRequestParams, dispatch, streamInvoke } from '@lobechat/electron-client-ipc';
|
||||
import { ProxyTRPCRequestParams, streamInvoke } from '@lobechat/electron-client-ipc';
|
||||
import { getRequestBody, headersToRecord } from '@lobechat/fetch-sse';
|
||||
import debug from 'debug';
|
||||
|
||||
import { getElectronStoreState } from '@/store/electron';
|
||||
import { electronSyncSelectors } from '@/store/electron/selectors';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const log = debug('utils:desktopRemoteRPCFetch');
|
||||
|
||||
@@ -30,7 +31,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
|
||||
urlPath,
|
||||
};
|
||||
|
||||
const ipcResult = await dispatch('proxyTRPCRequest', params);
|
||||
const ipcResult = await ensureElectronIpc().remoteServerSync.proxyTRPCRequest(params);
|
||||
|
||||
log(`Received ${url} IPC proxy response:`, { status: ipcResult.status });
|
||||
const response = new Response(ipcResult.body, {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getElectronIpc } from '@lobechat/electron-client-ipc';
|
||||
import type { DesktopIpcServices } from '@lobehub/desktop-ipc-typings';
|
||||
|
||||
export const ensureElectronIpc = (): DesktopIpcServices => {
|
||||
const ipc = getElectronIpc();
|
||||
if (!ipc) {
|
||||
throw new Error(
|
||||
'electronAPI.invoke not found. Ensure the preload exposes invoke via window.electronAPI.invoke',
|
||||
);
|
||||
}
|
||||
return ipc;
|
||||
};
|
||||
@@ -47,5 +47,10 @@
|
||||
".next/types/**/*.ts",
|
||||
"next-env.d.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./apps/desktop"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user