Skip to content

Commit f74befa

Browse files
authored
♻️ 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>
1 parent a775f65 commit f74befa

File tree

97 files changed

+1521
-857
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+1521
-857
lines changed

.cursor/rules/desktop-feature-implementation.mdc

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
3636
1. **创建控制器 (Controller)**
3737
- 位置:`apps/desktop/src/main/controllers/`
3838
- 示例:创建 `NewFeatureCtr.ts`
39-
- 规范:按 `_template.ts` 模板格式实现
40-
- 注册:在 `apps/desktop/src/main/controllers/index.ts` 导出
39+
- 需继承 `ControllerModule`,并设置 `static readonly groupName`(例如 `static override readonly groupName = 'newFeature';`)
40+
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors`(或 `controllerServerIpcConstructors`)中注册,保证类型推导与自动装配
4141

4242
2. **定义 IPC 事件处理器**
43-
- 使用 `@ipcClientEvent('eventName')` 装饰器注册事件处理函数
44-
- 处理函数应接收前端传递的参数并返回结果
45-
- 处理可能的错误情况
43+
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道,或使用 `@IpcServerMethod()` 声明仅供 Next.js 服务器调用的 IPC
44+
- 通道名称基于 `groupName.methodName` 自动生成,不再手动拼接字符串
45+
- 处理函数可通过 `getIpcContext()` 获取 `sender`、`event` 等上下文信息,并按照需要返回结构化结果
4646

4747
3. **实现业务逻辑**
4848
- 可能需要调用 Electron API 或 Node.js 原生模块
@@ -60,15 +60,17 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
6060
1. **创建服务层**
6161
- 位置:`src/services/electron/`
6262
- 添加服务方法调用 IPC
63-
- 使用 `dispatch` 或 `invoke` 函数
63+
- 使用 `ensureElectronIpc()` 生成的类型安全代理,避免手动拼通道名称
6464

6565
```typescript
6666
// src/services/electron/newFeatureService.ts
67-
import { dispatch } from '@lobechat/electron-client-ipc';
68-
import { NewFeatureParams } from 'types';
67+
import { ensureElectronIpc } from '@/utils/electron/ipc';
68+
import type { NewFeatureParams } from '@lobechat/electron-client-ipc';
69+
70+
const ipc = ensureElectronIpc();
6971

7072
export const newFeatureService = async (params: NewFeatureParams) => {
71-
return dispatch('newFeatureEventName', params);
73+
return ipc.newFeature.doSomething(params);
7274
};
7375
```
7476

@@ -118,36 +120,31 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
118120

119121
```typescript
120122
// apps/desktop/src/main/controllers/NotificationCtr.ts
121-
import { BrowserWindow, Notification } from 'electron';
122-
import { ipcClientEvent } from 'electron-client-ipc';
123-
124-
interface ShowNotificationParams {
125-
title: string;
126-
body: string;
127-
}
123+
import { Notification } from 'electron';
124+
import { ControllerModule, IpcMethod } from '@/controllers';
125+
import type {
126+
DesktopNotificationResult,
127+
ShowDesktopNotificationParams,
128+
} from '@lobechat/electron-client-ipc';
129+
130+
export default class NotificationCtr extends ControllerModule {
131+
static override readonly groupName = 'notification';
132+
133+
@IpcMethod()
134+
async showDesktopNotification(
135+
params: ShowDesktopNotificationParams,
136+
): Promise<DesktopNotificationResult> {
137+
if (!Notification.isSupported()) {
138+
return { error: 'Notifications not supported', success: false };
139+
}
128140

129-
export class NotificationCtr {
130-
@ipcClientEvent('showNotification')
131-
async handleShowNotification({ title, body }: ShowNotificationParams) {
132141
try {
133-
if (!Notification.isSupported()) {
134-
return { success: false, error: 'Notifications not supported' };
135-
}
136-
137-
const notification = new Notification({
138-
title,
139-
body,
140-
});
141-
142+
const notification = new Notification({ body: params.body, title: params.title });
142143
notification.show();
143-
144144
return { success: true };
145145
} catch (error) {
146-
console.error('Failed to show notification:', error);
147-
return {
148-
success: false,
149-
error: error instanceof Error ? error.message : 'Unknown error'
150-
};
146+
console.error('[NotificationCtr] Failed to show notification:', error);
147+
return { error: error instanceof Error ? error.message : 'Unknown error', success: false };
151148
}
152149
}
153150
}

.cursor/rules/desktop-local-tools-implement.mdc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ alwaysApply: false
5151
* 导入在步骤 2 中定义的 IPC 参数类型。
5252
* 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
5353
* 方法接收 `params` (符合 IPC 参数类型)。
54-
* 使用从 `@lobechat/electron-client-ipc` 导入的 `dispatch` (或 `invoke`) 函数,调用与 Manifest 中 `name` 字段匹配的 IPC 事件名称,并将 `params` 传递过去。
54+
* 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
5555
* 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
5656

5757
5. **实现后端逻辑 (Controller / IPC Handler):**
5858
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
5959
* **操作:**
60-
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ipcClientEvent`, 参数类型等)。
60+
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`/`IpcServerMethod`、参数类型等)。
6161
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
62-
* 使用 `@ipcClientEvent('yourApiName')` 装饰器将此方法注册为对应 IPC 事件的处理器,确保 `'yourApiName'` 与 Manifest 中的 `name` Service 层调用的事件名称一致
62+
* 使用 `@IpcMethod()` 或 `@IpcServerMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致
6363
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
6464
* 实现核心业务逻辑:
6565
* 进行必要的输入验证。

.cursor/rules/desktop-window-management.mdc

Lines changed: 56 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -149,50 +149,52 @@ export const createMainWindow = () => {
149149

150150
1. **在主进程中注册 IPC 处理器**
151151
```typescript
152-
// BrowserWindowsCtr.ts
153-
@ipcClientEvent('minimizeWindow')
154-
handleMinimizeWindow() {
155-
const focusedWindow = BrowserWindow.getFocusedWindow();
156-
if (focusedWindow) {
157-
focusedWindow.minimize();
152+
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
153+
import { BrowserWindow } from 'electron';
154+
import { ControllerModule, IpcMethod } from '@/controllers';
155+
156+
export default class BrowserWindowsCtr extends ControllerModule {
157+
static override readonly groupName = 'windows';
158+
159+
@IpcMethod()
160+
minimizeWindow() {
161+
const focusedWindow = BrowserWindow.getFocusedWindow();
162+
focusedWindow?.minimize();
163+
return { success: true };
158164
}
159-
return { success: true };
160-
}
161165

162-
@ipcClientEvent('maximizeWindow')
163-
handleMaximizeWindow() {
164-
const focusedWindow = BrowserWindow.getFocusedWindow();
165-
if (focusedWindow) {
166-
if (focusedWindow.isMaximized()) {
167-
focusedWindow.restore();
168-
} else {
169-
focusedWindow.maximize();
170-
}
166+
@IpcMethod()
167+
maximizeWindow() {
168+
const focusedWindow = BrowserWindow.getFocusedWindow();
169+
if (focusedWindow?.isMaximized()) focusedWindow.restore();
170+
else focusedWindow?.maximize();
171+
return { success: true };
171172
}
172-
return { success: true };
173-
}
174173

175-
@ipcClientEvent('closeWindow')
176-
handleCloseWindow() {
177-
const focusedWindow = BrowserWindow.getFocusedWindow();
178-
if (focusedWindow) {
179-
focusedWindow.close();
174+
@IpcMethod()
175+
closeWindow() {
176+
BrowserWindow.getFocusedWindow()?.close();
177+
return { success: true };
180178
}
181-
return { success: true };
182179
}
183180
```
181+
- `@IpcMethod()` 根据控制器的 `groupName` 自动将方法映射为 `windows.minimizeWindow` 形式的通道名称。
182+
- 控制器需继承 `ControllerModule`,并在 `controllers/registry.ts` 中通过 `controllerIpcConstructors` 注册,便于类型生成。
184183

185184
2. **在渲染进程中调用**
186185
```typescript
187186
// src/services/electron/windowService.ts
188-
import { dispatch } from '@lobechat/electron-client-ipc';
187+
import { ensureElectronIpc } from '@/utils/electron/ipc';
188+
189+
const ipc = ensureElectronIpc();
189190

190191
export const windowService = {
191-
minimize: () => dispatch('minimizeWindow'),
192-
maximize: () => dispatch('maximizeWindow'),
193-
close: () => dispatch('closeWindow'),
192+
minimize: () => ipc.windows.minimizeWindow(),
193+
maximize: () => ipc.windows.maximizeWindow(),
194+
close: () => ipc.windows.closeWindow(),
194195
};
195196
```
197+
- `ensureElectronIpc()` 会基于 `DesktopIpcServices` 运行时生成 Proxy,并通过 `window.electronAPI.invoke` 与主进程通信;不再直接使用 `dispatch`。
196198

197199
### 5. 自定义窗口控制 (无边框窗口)
198200

@@ -252,45 +254,33 @@ export const createMainWindow = () => {
252254

253255
```typescript
254256
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
257+
import type { OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
258+
import { ControllerModule, IpcMethod } from '@/controllers';
259+
260+
export default class BrowserWindowsCtr extends ControllerModule {
261+
static override readonly groupName = 'windows';
262+
263+
@IpcMethod()
264+
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
265+
const normalizedOptions =
266+
typeof options === 'string' || options === undefined
267+
? { tab: typeof options === 'string' ? options : undefined }
268+
: options;
269+
270+
const mainWindow = this.app.browserManager.getMainWindow();
271+
const query = new URLSearchParams();
272+
if (normalizedOptions.tab) query.set('active', normalizedOptions.tab);
273+
if (normalizedOptions.searchParams) {
274+
for (const [key, value] of Object.entries(normalizedOptions.searchParams)) {
275+
if (value) query.set(key, value);
276+
}
277+
}
278+
279+
const fullPath = `/settings${query.size ? `?${query.toString()}` : ''}`;
280+
await mainWindow.loadUrl(fullPath);
281+
mainWindow.show();
255282

256-
@ipcClientEvent('openSettings')
257-
handleOpenSettings() {
258-
// 检查设置窗口是否已经存在
259-
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
260-
// 如果窗口已存在,将其置于前台
261-
this.settingsWindow.focus();
262283
return { success: true };
263284
}
264-
265-
// 创建新窗口
266-
this.settingsWindow = new BrowserWindow({
267-
width: 800,
268-
height: 600,
269-
title: 'Settings',
270-
parent: this.mainWindow, // 设置父窗口,使其成为模态窗口
271-
modal: true,
272-
webPreferences: {
273-
preload: path.join(__dirname, '../preload/index.js'),
274-
contextIsolation: true,
275-
nodeIntegration: false,
276-
},
277-
});
278-
279-
// 加载设置页面
280-
if (isDev) {
281-
this.settingsWindow.loadURL('http://localhost:3000/settings');
282-
} else {
283-
this.settingsWindow.loadFile(
284-
path.join(__dirname, '../../renderer/index.html'),
285-
{ hash: 'settings' }
286-
);
287-
}
288-
289-
// 监听窗口关闭事件
290-
this.settingsWindow.on('closed', () => {
291-
this.settingsWindow = null;
292-
});
293-
294-
return { success: true };
295285
}
296286
```

apps/desktop/Development.md

Lines changed: 42 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -156,24 +156,26 @@ apps/desktop/src/main/
156156
- 事件广播:向渲染进程通知授权状态变化
157157

158158
```typescript
159-
// 认证流程示例
160-
@ipcClientEvent('requestAuthorization')
161-
async requestAuthorization(config: DataSyncConfig) {
162-
// 生成状态参数防止 CSRF 攻击
163-
this.authRequestState = crypto.randomBytes(16).toString('hex');
164-
165-
// 构建授权 URL
166-
const authUrl = new URL('/oidc/auth', remoteUrl);
167-
authUrl.search = querystring.stringify({
168-
client_id: 'lobe-chat',
169-
response_type: 'code',
170-
redirect_uri: `${protocolPrefix}://auth/callback`,
171-
scope: 'openid profile',
172-
state: this.authRequestState,
173-
});
174-
175-
// 在默认浏览器中打开授权 URL
176-
await shell.openExternal(authUrl.toString());
159+
import { ControllerModule, IpcMethod } from '@/controllers';
160+
161+
export default class AuthCtr extends ControllerModule {
162+
static override groupName = 'auth';
163+
164+
@IpcMethod()
165+
async requestAuthorization(config: DataSyncConfig) {
166+
this.authRequestState = crypto.randomBytes(16).toString('hex');
167+
168+
const authUrl = new URL('/oidc/auth', remoteUrl);
169+
authUrl.search = querystring.stringify({
170+
client_id: 'lobe-chat',
171+
redirect_uri: `${protocolPrefix}://auth/callback`,
172+
response_type: 'code',
173+
scope: 'openid profile',
174+
state: this.authRequestState,
175+
});
176+
177+
await shell.openExternal(authUrl.toString());
178+
}
177179
}
178180
```
179181

@@ -267,20 +269,27 @@ export class ShortcutManager {
267269
- 注入 App 实例
268270

269271
```typescript
270-
// 控制器基类和装饰器
272+
import { ControllerModule, IpcMethod, IpcServerMethod } from '@/controllers'
273+
271274
export class ControllerModule implements IControllerModule {
272275
constructor(public app: App) {
273-
this.app = app;
276+
this.app = app
274277
}
275278
}
276279

277-
// IPC 客户端事件装饰器
278-
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
279-
ipcDecorator(method, 'client');
280+
export class BrowserWindowsCtr extends ControllerModule {
281+
static override readonly groupName = 'windows' // must be readonly
282+
283+
@IpcMethod()
284+
openSettingsWindow(params?: OpenSettingsWindowOptions) {
285+
// ...
286+
}
280287

281-
// IPC 服务器事件装饰器
282-
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
283-
ipcDecorator(method, 'server');
288+
@IpcServerMethod()
289+
handleServerCommand(payload: any) {
290+
// ...
291+
}
292+
}
284293
```
285294

286295
2. **IoC 容器**
@@ -346,26 +355,13 @@ makeSureDirExist(storagePath);
346355
- 自动映射控制器方法到 IPC 事件
347356

348357
```typescript
349-
// IPC 事件初始化
350-
private initializeIPCEvents() {
351-
// 注册客户端事件处理程序
352-
this.ipcClientEventMap.forEach((eventInfo, key) => {
353-
ipcMain.handle(key, async (e, ...data) => {
354-
return await eventInfo.controller[eventInfo.methodName](...data);
355-
});
356-
});
357-
358-
// 注册服务器事件处理程序
359-
const ipcServerEvents = {} as ElectronIPCEventHandler;
360-
this.ipcServerEventMap.forEach((eventInfo, key) => {
361-
ipcServerEvents[key] = async (payload) => {
362-
return await eventInfo.controller[eventInfo.methodName](payload);
363-
};
364-
});
365-
366-
// 创建 IPC 服务器
367-
this.ipcServer = new ElectronIPCServer(name, ipcServerEvents);
368-
}
358+
import { ensureElectronIpc } from '@/utils/electron/ipc';
359+
360+
// 渲染进程中使用 type-safe proxy 调用主进程方法
361+
const ipc = ensureElectronIpc();
362+
363+
await ipc.localSystem.readLocalFile({ path });
364+
await ipc.system.updateLocale('en-US');
369365
```
370366

371367
2. **事件广播**

0 commit comments

Comments
 (0)