@lobehub/lobehub 2.0.0-next.164 → 2.0.0-next.165
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/desktop-feature-implementation.mdc +31 -34
- package/.cursor/rules/desktop-local-tools-implement.mdc +3 -3
- package/.cursor/rules/desktop-window-management.mdc +56 -66
- package/CHANGELOG.md +25 -0
- package/README.md +6 -6
- package/README.zh-CN.md +6 -6
- package/apps/desktop/Development.md +42 -46
- package/apps/desktop/README.md +37 -1
- package/apps/desktop/README.zh-CN.md +26 -1
- package/apps/desktop/electron.vite.config.ts +1 -0
- package/apps/desktop/src/main/controllers/AuthCtr.ts +4 -3
- package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +33 -20
- package/apps/desktop/src/main/controllers/DevtoolsCtr.ts +4 -2
- package/apps/desktop/src/main/controllers/LocalFileCtr.ts +14 -13
- package/apps/desktop/src/main/controllers/MenuCtr.ts +5 -4
- package/apps/desktop/src/main/controllers/NetworkProxyCtr.ts +18 -19
- package/apps/desktop/src/main/controllers/NotificationCtr.ts +4 -3
- package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +5 -4
- package/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts +3 -2
- package/apps/desktop/src/main/controllers/ShellCommandCtr.ts +5 -4
- package/apps/desktop/src/main/controllers/ShortcutCtr.ts +4 -3
- package/apps/desktop/src/main/controllers/SystemCtr.ts +7 -37
- package/apps/desktop/src/main/controllers/SystemServerCtr.ts +38 -0
- package/apps/desktop/src/main/controllers/TrayMenuCtr.ts +5 -4
- package/apps/desktop/src/main/controllers/UpdaterCtr.ts +6 -5
- package/apps/desktop/src/main/controllers/UploadFileCtr.ts +3 -25
- package/apps/desktop/src/main/controllers/UploadFileServerCtr.ts +33 -0
- package/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts +9 -1
- package/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts +29 -9
- package/apps/desktop/src/main/controllers/__tests__/DevtoolsCtr.test.ts +12 -3
- package/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +7 -0
- package/apps/desktop/src/main/controllers/__tests__/MenuCtr.test.ts +10 -0
- package/apps/desktop/src/main/controllers/__tests__/NetworkProxyCtr.test.ts +10 -0
- package/apps/desktop/src/main/controllers/__tests__/NotificationCtr.test.ts +8 -0
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +8 -0
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerSyncCtr.test.ts +1 -0
- package/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts +10 -0
- package/apps/desktop/src/main/controllers/__tests__/ShortcutCtr.test.ts +11 -0
- package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +43 -73
- package/apps/desktop/src/main/controllers/__tests__/SystemServerCtr.test.ts +75 -0
- package/apps/desktop/src/main/controllers/__tests__/TrayMenuCtr.test.ts +24 -13
- package/apps/desktop/src/main/controllers/__tests__/UpdaterCtr.test.ts +13 -2
- package/apps/desktop/src/main/controllers/__tests__/UploadFileCtr.test.ts +29 -108
- package/apps/desktop/src/main/controllers/__tests__/UploadFileServerCtr.test.ts +55 -0
- package/apps/desktop/src/main/controllers/_template.ts +2 -2
- package/apps/desktop/src/main/controllers/index.ts +5 -29
- package/apps/desktop/src/main/controllers/registry.ts +52 -0
- package/apps/desktop/src/main/core/App.ts +15 -47
- package/apps/desktop/src/main/core/__tests__/App.test.ts +5 -4
- package/apps/desktop/src/main/core/infrastructure/IoCContainer.ts +0 -5
- package/apps/desktop/src/main/core/infrastructure/__tests__/IoCContainer.test.ts +0 -50
- package/apps/desktop/src/main/exports.d.ts +8 -0
- package/apps/desktop/src/main/exports.ts +2 -0
- package/apps/desktop/src/main/global.d.ts +3 -0
- package/apps/desktop/src/main/modules/fileSearch/__tests__/macOS.integration.test.ts +17 -8
- package/apps/desktop/src/main/package.json +10 -0
- package/apps/desktop/src/main/services/fileSrv.ts +1 -1
- package/apps/desktop/src/main/utils/ipc/__tests__/base.test.ts +91 -0
- package/apps/desktop/src/main/utils/ipc/base.ts +170 -0
- package/apps/desktop/src/main/utils/ipc/index.ts +11 -0
- package/apps/desktop/src/main/utils/ipc/utility.ts +20 -0
- package/apps/desktop/src/preload/electronApi.ts +4 -1
- package/apps/desktop/src/preload/invoke.test.ts +13 -16
- package/apps/desktop/src/preload/invoke.ts +2 -5
- package/apps/desktop/src/preload/routeInterceptor.test.ts +13 -13
- package/apps/desktop/src/preload/routeInterceptor.ts +4 -4
- package/apps/desktop/tsconfig.json +15 -5
- package/changelog/v1.json +5 -0
- package/package.json +4 -3
- package/packages/electron-client-ipc/src/index.ts +1 -1
- package/packages/electron-client-ipc/src/ipc.test.ts +62 -0
- package/packages/electron-client-ipc/src/ipc.ts +63 -0
- package/packages/electron-client-ipc/src/streamInvoke.ts +7 -1
- package/packages/electron-client-ipc/src/types/dispatch.ts +1 -10
- package/packages/electron-client-ipc/vitest.config.mts +10 -0
- package/packages/electron-server-ipc/src/ipcClient.ts +1 -2
- package/packages/electron-server-ipc/src/ipcServer.ts +1 -2
- package/packages/electron-server-ipc/src/types/index.ts +1 -5
- package/pnpm-workspace.yaml +1 -1
- package/scripts/i18nWorkflow/const.ts +2 -2
- package/scripts/i18nWorkflow/i18nConfig.ts +7 -0
- package/scripts/i18nWorkflow/utils.ts +1 -1
- package/src/app/[variants]/(main)/discover/(detail)/provider/features/Sidebar/ActionButton/ProviderConfig.tsx +2 -2
- package/src/locales/default/setting.ts +1 -0
- package/src/server/modules/ElectronIPCClient/index.ts +59 -13
- package/src/services/electron/__tests__/devtools.test.ts +10 -6
- package/src/services/electron/autoUpdate.ts +5 -5
- package/src/services/electron/desktopNotification.ts +4 -7
- package/src/services/electron/devtools.ts +2 -2
- package/src/services/electron/file.ts +3 -2
- package/src/services/electron/localFileService.ts +17 -16
- package/src/services/electron/remoteServer.ts +7 -6
- package/src/services/electron/settings.ts +9 -11
- package/src/services/electron/system.ts +8 -6
- package/src/store/chat/slices/plugin/actions/pluginTypes.ts +1 -1
- package/src/store/global/actions/general.ts +8 -10
- package/src/utils/electron/desktopRemoteRPCFetch.ts +3 -2
- package/src/utils/electron/ipc.ts +12 -0
- package/tsconfig.json +5 -0
- package/apps/desktop/src/main/types/ipcClientEvent.ts +0 -3
- package/packages/electron-client-ipc/src/dispatch.ts +0 -41
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { IpcMainInvokeEvent, WebContents } from 'electron';
|
|
2
|
+
import { ipcMain } from 'electron';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
|
|
5
|
+
// Base context for IPC methods
|
|
6
|
+
export interface IpcContext {
|
|
7
|
+
event: IpcMainInvokeEvent;
|
|
8
|
+
sender: WebContents;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Metadata storage for decorated methods
|
|
12
|
+
const methodMetadata = new WeakMap<any, Map<string, string>>();
|
|
13
|
+
const serverMethodMetadata = new WeakMap<any, Map<string, string>>();
|
|
14
|
+
const ipcContextStorage = new AsyncLocalStorage<IpcContext>();
|
|
15
|
+
|
|
16
|
+
// Decorator for IPC methods
|
|
17
|
+
export function IpcMethod() {
|
|
18
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
19
|
+
const { constructor } = target;
|
|
20
|
+
|
|
21
|
+
if (!methodMetadata.has(constructor)) {
|
|
22
|
+
methodMetadata.set(constructor, new Map());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const methods = methodMetadata.get(constructor)!;
|
|
26
|
+
methods.set(propertyKey, propertyKey);
|
|
27
|
+
|
|
28
|
+
return descriptor;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function IpcServerMethod(channelName?: string) {
|
|
33
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
34
|
+
const { constructor } = target;
|
|
35
|
+
|
|
36
|
+
if (!serverMethodMetadata.has(constructor)) {
|
|
37
|
+
serverMethodMetadata.set(constructor, new Map());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const methods = serverMethodMetadata.get(constructor)!;
|
|
41
|
+
methods.set(propertyKey, channelName || propertyKey);
|
|
42
|
+
|
|
43
|
+
return descriptor;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handler registry for IPC methods
|
|
48
|
+
export class IpcHandler {
|
|
49
|
+
private static instance: IpcHandler;
|
|
50
|
+
private registeredChannels = new Set<string>();
|
|
51
|
+
|
|
52
|
+
static getInstance(): IpcHandler {
|
|
53
|
+
if (!IpcHandler.instance) {
|
|
54
|
+
IpcHandler.instance = new IpcHandler();
|
|
55
|
+
}
|
|
56
|
+
return IpcHandler.instance;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
registerMethod<TArgs extends unknown[], TOutput>(
|
|
60
|
+
channel: string,
|
|
61
|
+
handler: (...args: TArgs) => Promise<TOutput> | TOutput,
|
|
62
|
+
) {
|
|
63
|
+
if (this.registeredChannels.has(channel)) {
|
|
64
|
+
return; // Already registered
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.registeredChannels.add(channel);
|
|
68
|
+
|
|
69
|
+
ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: any[]) => {
|
|
70
|
+
const context: IpcContext = {
|
|
71
|
+
event,
|
|
72
|
+
sender: event.sender,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return ipcContextStorage.run(context, async () => {
|
|
76
|
+
try {
|
|
77
|
+
const typedArgs = args as TArgs;
|
|
78
|
+
return await handler(...typedArgs);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`Error in IPC method ${channel}:`, error);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Send events to renderer
|
|
88
|
+
sendToRenderer<T = any>(webContents: WebContents, channel: string, data: T) {
|
|
89
|
+
webContents.send(channel, data);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Base class for IPC service groups
|
|
94
|
+
export abstract class IpcService {
|
|
95
|
+
protected handler = IpcHandler.getInstance();
|
|
96
|
+
static readonly groupName: string;
|
|
97
|
+
|
|
98
|
+
constructor() {
|
|
99
|
+
this.registerMethods();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected registerMethods(): void {
|
|
103
|
+
const { constructor } = this;
|
|
104
|
+
const methods = methodMetadata.get(constructor);
|
|
105
|
+
|
|
106
|
+
if (methods) {
|
|
107
|
+
methods.forEach((methodName, propertyKey) => {
|
|
108
|
+
const method = (this as any)[propertyKey];
|
|
109
|
+
if (typeof method === 'function') {
|
|
110
|
+
this.registerMethod(methodName, method.bind(this));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
protected registerMethod<TArgs extends unknown[], TOutput>(
|
|
117
|
+
methodName: string,
|
|
118
|
+
handler: (...args: TArgs) => Promise<TOutput> | TOutput,
|
|
119
|
+
) {
|
|
120
|
+
const groupName = (this.constructor as typeof IpcService).groupName;
|
|
121
|
+
const channel = `${groupName}.${methodName}`;
|
|
122
|
+
this.handler.registerMethod(channel, handler);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Service constructor with groupName
|
|
127
|
+
export interface IpcServiceConstructor {
|
|
128
|
+
new (...args: any[]): IpcService;
|
|
129
|
+
readonly groupName: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Create services function that infers types from service constructors
|
|
133
|
+
export function createServices<T extends readonly IpcServiceConstructor[]>(
|
|
134
|
+
serviceConstructors: T,
|
|
135
|
+
...constructorArgs: any[]
|
|
136
|
+
): CreateServicesResult<T> {
|
|
137
|
+
const services = {} as any;
|
|
138
|
+
|
|
139
|
+
for (const ServiceConstructor of serviceConstructors) {
|
|
140
|
+
const instance = new ServiceConstructor(...constructorArgs);
|
|
141
|
+
const groupName = ServiceConstructor.groupName;
|
|
142
|
+
|
|
143
|
+
if (!groupName) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Service ${ServiceConstructor.name} must define a static readonly groupName property`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
services[groupName] = instance;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return services;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Helper type for createServices return type
|
|
156
|
+
export type CreateServicesResult<T extends readonly IpcServiceConstructor[]> = {
|
|
157
|
+
[K in T[number] as K['groupName']]: InstanceType<K>;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export function getServerMethodMetadata(target: IpcServiceConstructor) {
|
|
161
|
+
return serverMethodMetadata.get(target);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getIpcContext() {
|
|
165
|
+
return ipcContextStorage.getStore();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function runWithIpcContext<T>(context: IpcContext, callback: () => T): T {
|
|
169
|
+
return ipcContextStorage.run(context, callback);
|
|
170
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { CreateServicesResult, IpcContext, IpcServiceConstructor } from './base';
|
|
2
|
+
export {
|
|
3
|
+
createServices,
|
|
4
|
+
getIpcContext,
|
|
5
|
+
getServerMethodMetadata,
|
|
6
|
+
IpcMethod,
|
|
7
|
+
IpcServerMethod,
|
|
8
|
+
IpcService,
|
|
9
|
+
runWithIpcContext,
|
|
10
|
+
} from './base';
|
|
11
|
+
export type { ExtractServiceMethods, MergeIpcService } from './utility';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Extract method signatures from service classes
|
|
2
|
+
type ExtractMethodSignature<T> = T extends (...args: infer Args) => infer Output
|
|
3
|
+
? (...args: Args) => AlwaysPromise<Output>
|
|
4
|
+
: never;
|
|
5
|
+
|
|
6
|
+
export type ExtractServiceMethods<T> = {
|
|
7
|
+
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: ExtractMethodSignature<T[K]>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type AlwaysPromise<T> = Promise<Awaited<T>>;
|
|
11
|
+
|
|
12
|
+
// TypeScript utility type to automatically merge IPC services
|
|
13
|
+
// This version works with both the old object format and new createServices format
|
|
14
|
+
export type MergeIpcService<T> = {
|
|
15
|
+
[K in keyof T]: T[K] extends new (...args: any[]) => infer Instance
|
|
16
|
+
? ExtractServiceMethods<Instance>
|
|
17
|
+
: T[K] extends infer Instance
|
|
18
|
+
? ExtractServiceMethods<Instance>
|
|
19
|
+
: never;
|
|
20
|
+
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ClientDispatchEventKey } from '@lobechat/electron-client-ipc';
|
|
2
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
2
|
|
|
4
3
|
// Mock electron module
|
|
@@ -21,9 +20,9 @@ describe('invoke', () => {
|
|
|
21
20
|
const expectedResult = { success: true };
|
|
22
21
|
mockIpcRendererInvoke.mockResolvedValue(expectedResult);
|
|
23
22
|
|
|
24
|
-
const result = await invoke('getAppVersion'
|
|
23
|
+
const result = await invoke('system.getAppVersion');
|
|
25
24
|
|
|
26
|
-
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('getAppVersion');
|
|
25
|
+
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('system.getAppVersion');
|
|
27
26
|
expect(mockIpcRendererInvoke).toHaveBeenCalledTimes(1);
|
|
28
27
|
expect(result).toEqual(expectedResult);
|
|
29
28
|
});
|
|
@@ -33,9 +32,9 @@ describe('invoke', () => {
|
|
|
33
32
|
const expectedResult = { navigated: true };
|
|
34
33
|
mockIpcRendererInvoke.mockResolvedValue(expectedResult);
|
|
35
34
|
|
|
36
|
-
const result = await invoke('interceptRoute'
|
|
35
|
+
const result = await invoke('windows.interceptRoute', eventData);
|
|
37
36
|
|
|
38
|
-
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('interceptRoute', eventData);
|
|
37
|
+
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('windows.interceptRoute', eventData);
|
|
39
38
|
expect(mockIpcRendererInvoke).toHaveBeenCalledTimes(1);
|
|
40
39
|
expect(result).toEqual(expectedResult);
|
|
41
40
|
});
|
|
@@ -59,16 +58,14 @@ describe('invoke', () => {
|
|
|
59
58
|
const error = new Error('IPC communication failed');
|
|
60
59
|
mockIpcRendererInvoke.mockRejectedValue(error);
|
|
61
60
|
|
|
62
|
-
await expect(invoke('getAppVersion'
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('getAppVersion');
|
|
61
|
+
await expect(invoke('system.getAppVersion')).rejects.toThrow('IPC communication failed');
|
|
62
|
+
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('system.getAppVersion');
|
|
66
63
|
});
|
|
67
64
|
|
|
68
65
|
it('should handle ipcRenderer returning undefined', async () => {
|
|
69
66
|
mockIpcRendererInvoke.mockResolvedValue(undefined);
|
|
70
67
|
|
|
71
|
-
const result = await invoke('someEvent'
|
|
68
|
+
const result = await invoke('someEvent');
|
|
72
69
|
|
|
73
70
|
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('someEvent');
|
|
74
71
|
expect(result).toBeUndefined();
|
|
@@ -77,7 +74,7 @@ describe('invoke', () => {
|
|
|
77
74
|
it('should handle ipcRenderer returning null', async () => {
|
|
78
75
|
mockIpcRendererInvoke.mockResolvedValue(null);
|
|
79
76
|
|
|
80
|
-
const result = await invoke('someEvent'
|
|
77
|
+
const result = await invoke('someEvent');
|
|
81
78
|
|
|
82
79
|
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('someEvent');
|
|
83
80
|
expect(result).toBeNull();
|
|
@@ -96,7 +93,7 @@ describe('invoke', () => {
|
|
|
96
93
|
};
|
|
97
94
|
mockIpcRendererInvoke.mockResolvedValue(complexData);
|
|
98
95
|
|
|
99
|
-
const result = await invoke('getData'
|
|
96
|
+
const result = await invoke('getData');
|
|
100
97
|
|
|
101
98
|
expect(result).toEqual(complexData);
|
|
102
99
|
});
|
|
@@ -125,9 +122,9 @@ describe('invoke', () => {
|
|
|
125
122
|
.mockResolvedValueOnce({ id: 3 });
|
|
126
123
|
|
|
127
124
|
const [result1, result2, result3] = await Promise.all([
|
|
128
|
-
invoke('event1'
|
|
129
|
-
invoke('event2'
|
|
130
|
-
invoke('event3'
|
|
125
|
+
invoke('event1'),
|
|
126
|
+
invoke('event2'),
|
|
127
|
+
invoke('event3'),
|
|
131
128
|
]);
|
|
132
129
|
|
|
133
130
|
expect(result1).toEqual({ id: 1 });
|
|
@@ -139,7 +136,7 @@ describe('invoke', () => {
|
|
|
139
136
|
it('should handle empty string as data parameter', async () => {
|
|
140
137
|
mockIpcRendererInvoke.mockResolvedValue({ received: '' });
|
|
141
138
|
|
|
142
|
-
const result = await invoke('sendData'
|
|
139
|
+
const result = await invoke('sendData', '');
|
|
143
140
|
|
|
144
141
|
expect(mockIpcRendererInvoke).toHaveBeenCalledWith('sendData', '');
|
|
145
142
|
expect(result).toEqual({ received: '' });
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DispatchInvoke } from '@lobechat/electron-client-ipc';
|
|
2
2
|
import { ipcRenderer } from 'electron';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Client-side method to invoke electron main process
|
|
6
6
|
*/
|
|
7
|
-
export const invoke: DispatchInvoke = async
|
|
8
|
-
event: T,
|
|
9
|
-
...data: any[]
|
|
10
|
-
) => ipcRenderer.invoke(event, ...data);
|
|
7
|
+
export const invoke: DispatchInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
|
|
@@ -46,7 +46,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
46
46
|
const externalUrl = 'https://google.com';
|
|
47
47
|
const result = window.open(externalUrl, '_blank');
|
|
48
48
|
|
|
49
|
-
expect(invoke).toHaveBeenCalledWith('openExternalLink', externalUrl);
|
|
49
|
+
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', externalUrl);
|
|
50
50
|
expect(result).toBeNull();
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -56,7 +56,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
56
56
|
const externalUrl = new URL('https://github.com');
|
|
57
57
|
const result = window.open(externalUrl, '_blank');
|
|
58
58
|
|
|
59
|
-
expect(invoke).toHaveBeenCalledWith('openExternalLink', 'https://github.com/');
|
|
59
|
+
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', 'https://github.com/');
|
|
60
60
|
expect(result).toBeNull();
|
|
61
61
|
});
|
|
62
62
|
|
|
@@ -69,7 +69,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
69
69
|
// We can't fully test the original behavior in happy-dom, but we can verify invoke is not called
|
|
70
70
|
window.open(internalUrl);
|
|
71
71
|
|
|
72
|
-
expect(invoke).not.toHaveBeenCalledWith('openExternalLink', expect.anything());
|
|
72
|
+
expect(invoke).not.toHaveBeenCalledWith('system.openExternalLink', expect.anything());
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it('should handle relative URL that resolves as internal link', () => {
|
|
@@ -81,7 +81,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
81
81
|
window.open(relativeUrl);
|
|
82
82
|
|
|
83
83
|
// Since it's internal, it won't call invoke for external link
|
|
84
|
-
expect(invoke).not.toHaveBeenCalledWith('openExternalLink', expect.anything());
|
|
84
|
+
expect(invoke).not.toHaveBeenCalledWith('system.openExternalLink', expect.anything());
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
87
|
|
|
@@ -102,7 +102,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
102
102
|
// Wait for async handling
|
|
103
103
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
104
104
|
|
|
105
|
-
expect(invoke).toHaveBeenCalledWith('openExternalLink', 'https://example.com/');
|
|
105
|
+
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', 'https://example.com/');
|
|
106
106
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
107
107
|
expect(stopPropagationSpy).toHaveBeenCalled();
|
|
108
108
|
});
|
|
@@ -129,7 +129,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
129
129
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
130
130
|
|
|
131
131
|
expect(findMatchingRoute).toHaveBeenCalledWith('/desktop/devtools');
|
|
132
|
-
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
|
132
|
+
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
|
133
133
|
path: '/desktop/devtools',
|
|
134
134
|
source: 'link-click',
|
|
135
135
|
url: 'http://localhost:3000/desktop/devtools',
|
|
@@ -166,7 +166,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
166
166
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
167
167
|
|
|
168
168
|
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
|
169
|
-
expect(invoke).not.toHaveBeenCalledWith('interceptRoute', expect.anything());
|
|
169
|
+
expect(invoke).not.toHaveBeenCalledWith('windows.interceptRoute', expect.anything());
|
|
170
170
|
});
|
|
171
171
|
|
|
172
172
|
it('should handle non-HTTP link protocols as external links', async () => {
|
|
@@ -184,7 +184,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
184
184
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
185
185
|
|
|
186
186
|
// mailto: links are treated as external links by the URL constructor
|
|
187
|
-
expect(invoke).toHaveBeenCalledWith('openExternalLink', 'mailto:test@example.com');
|
|
187
|
+
expect(invoke).toHaveBeenCalledWith('system.openExternalLink', 'mailto:test@example.com');
|
|
188
188
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
189
189
|
});
|
|
190
190
|
});
|
|
@@ -205,7 +205,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
205
205
|
history.pushState({}, '', '/desktop/devtools');
|
|
206
206
|
|
|
207
207
|
expect(findMatchingRoute).toHaveBeenCalledWith('/desktop/devtools');
|
|
208
|
-
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
|
208
|
+
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
|
209
209
|
path: '/desktop/devtools',
|
|
210
210
|
source: 'push-state',
|
|
211
211
|
url: 'http://localhost:3000/desktop/devtools',
|
|
@@ -245,7 +245,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
245
245
|
|
|
246
246
|
history.pushState({}, '', '/chat/new');
|
|
247
247
|
|
|
248
|
-
expect(invoke).not.toHaveBeenCalledWith('interceptRoute', expect.anything());
|
|
248
|
+
expect(invoke).not.toHaveBeenCalledWith('windows.interceptRoute', expect.anything());
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
it('should handle pushState errors gracefully', () => {
|
|
@@ -279,7 +279,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
279
279
|
history.replaceState({}, '', '/desktop/devtools');
|
|
280
280
|
|
|
281
281
|
expect(findMatchingRoute).toHaveBeenCalledWith('/desktop/devtools');
|
|
282
|
-
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
|
282
|
+
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
|
283
283
|
path: '/desktop/devtools',
|
|
284
284
|
source: 'replace-state',
|
|
285
285
|
url: 'http://localhost:3000/desktop/devtools',
|
|
@@ -317,7 +317,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
317
317
|
|
|
318
318
|
history.replaceState({}, '', '/chat/session-123');
|
|
319
319
|
|
|
320
|
-
expect(invoke).not.toHaveBeenCalledWith('interceptRoute', expect.anything());
|
|
320
|
+
expect(invoke).not.toHaveBeenCalledWith('windows.interceptRoute', expect.anything());
|
|
321
321
|
});
|
|
322
322
|
});
|
|
323
323
|
|
|
@@ -385,7 +385,7 @@ describe('setupRouteInterceptors', () => {
|
|
|
385
385
|
|
|
386
386
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
387
387
|
|
|
388
|
-
expect(invoke).toHaveBeenCalledWith('interceptRoute', {
|
|
388
|
+
expect(invoke).toHaveBeenCalledWith('windows.interceptRoute', {
|
|
389
389
|
path: '/desktop/devtools',
|
|
390
390
|
source: 'push-state',
|
|
391
391
|
url: 'http://localhost:3000/desktop/devtools',
|
|
@@ -11,7 +11,7 @@ const interceptRoute = async (
|
|
|
11
11
|
|
|
12
12
|
// Use electron-client-ipc's dispatch method
|
|
13
13
|
try {
|
|
14
|
-
await invoke('interceptRoute', { path, source, url });
|
|
14
|
+
await invoke('windows.interceptRoute', { path, source, url });
|
|
15
15
|
} catch (e) {
|
|
16
16
|
console.error(`[preload] Route interception (${source}) call failed`, e);
|
|
17
17
|
}
|
|
@@ -37,14 +37,14 @@ export const setupRouteInterceptors = function () {
|
|
|
37
37
|
if (urlObj.origin !== window.location.origin) {
|
|
38
38
|
console.log(`[preload] Intercepted window.open for external URL:`, urlString);
|
|
39
39
|
// Call main process to handle external link
|
|
40
|
-
invoke('openExternalLink', urlString);
|
|
40
|
+
invoke('system.openExternalLink', urlString);
|
|
41
41
|
return null; // Return null to indicate no window was opened
|
|
42
42
|
}
|
|
43
43
|
} catch (error) {
|
|
44
44
|
// Handle invalid URL or special protocol
|
|
45
45
|
console.error(`[preload] Intercepted window.open for special protocol:`, url);
|
|
46
46
|
console.error(error);
|
|
47
|
-
invoke('openExternalLink', typeof url === 'string' ? url : url.toString());
|
|
47
|
+
invoke('system.openExternalLink', typeof url === 'string' ? url : url.toString());
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -69,7 +69,7 @@ export const setupRouteInterceptors = function () {
|
|
|
69
69
|
e.preventDefault();
|
|
70
70
|
e.stopPropagation();
|
|
71
71
|
// Call main process to handle external link
|
|
72
|
-
await invoke('openExternalLink', url.href);
|
|
72
|
+
await invoke('system.openExternalLink', url.href);
|
|
73
73
|
return false; // Explicitly prevent subsequent processing
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -3,18 +3,28 @@
|
|
|
3
3
|
"allowJs": true,
|
|
4
4
|
"skipLibCheck": true,
|
|
5
5
|
"forceConsistentCasingInFileNames": true,
|
|
6
|
-
"noEmit": true,
|
|
7
6
|
"target": "ESNext",
|
|
7
|
+
"emitDeclarationOnly": true,
|
|
8
8
|
"esModuleInterop": true,
|
|
9
9
|
"emitDecoratorMetadata": true,
|
|
10
|
+
"composite": true,
|
|
11
|
+
"baseUrl": ".",
|
|
10
12
|
"experimentalDecorators": true,
|
|
11
13
|
"module": "esnext",
|
|
12
14
|
"moduleResolution": "bundler",
|
|
13
15
|
"resolveJsonModule": true,
|
|
14
16
|
"paths": {
|
|
15
|
-
"@/*": [
|
|
16
|
-
|
|
17
|
+
"@/*": [
|
|
18
|
+
"src/main/*"
|
|
19
|
+
],
|
|
20
|
+
"~common/*": [
|
|
21
|
+
"src/common/*"
|
|
22
|
+
]
|
|
17
23
|
}
|
|
18
24
|
},
|
|
19
|
-
"include": [
|
|
20
|
-
|
|
25
|
+
"include": [
|
|
26
|
+
"src/main/**/*",
|
|
27
|
+
"src/preload/**/*",
|
|
28
|
+
"electron-builder.js"
|
|
29
|
+
]
|
|
30
|
+
}
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.165",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"sideEffects": false,
|
|
28
28
|
"workspaces": [
|
|
29
29
|
"packages/*",
|
|
30
|
-
"e2e"
|
|
30
|
+
"e2e",
|
|
31
|
+
"apps/desktop/src/main"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
34
|
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
|
|
@@ -169,6 +170,7 @@
|
|
|
169
170
|
"@lobehub/charts": "^2.1.2",
|
|
170
171
|
"@lobehub/chat-plugin-sdk": "^1.32.4",
|
|
171
172
|
"@lobehub/chat-plugins-gateway": "^1.9.0",
|
|
173
|
+
"@lobehub/desktop-ipc-typings": "workspace:*",
|
|
172
174
|
"@lobehub/editor": "^1.23.1",
|
|
173
175
|
"@lobehub/icons": "^2.43.1",
|
|
174
176
|
"@lobehub/market-sdk": "^0.23.2",
|
|
@@ -338,7 +340,6 @@
|
|
|
338
340
|
"@types/crypto-js": "^4.2.2",
|
|
339
341
|
"@types/debug": "^4.1.12",
|
|
340
342
|
"@types/fs-extra": "^11.0.4",
|
|
341
|
-
"@types/ioredis": "^5.0.0",
|
|
342
343
|
"@types/ip": "^1.1.3",
|
|
343
344
|
"@types/json-schema": "^7.0.15",
|
|
344
345
|
"@types/lodash": "^4.17.21",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const restoreWindow = (() => {
|
|
4
|
+
const originalWindow = (globalThis as any).window;
|
|
5
|
+
|
|
6
|
+
return () => {
|
|
7
|
+
if (originalWindow === undefined) {
|
|
8
|
+
Reflect.deleteProperty(globalThis as any, 'window');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
(globalThis as any).window = originalWindow;
|
|
12
|
+
};
|
|
13
|
+
})();
|
|
14
|
+
|
|
15
|
+
describe('getElectronIpc', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.resetModules();
|
|
18
|
+
restoreWindow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
restoreWindow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns null when window is not defined', async () => {
|
|
26
|
+
Reflect.deleteProperty(globalThis as any, 'window');
|
|
27
|
+
const { getElectronIpc } = await import('./ipc');
|
|
28
|
+
|
|
29
|
+
expect(getElectronIpc()).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns null when invoke is missing on electronAPI', async () => {
|
|
33
|
+
(globalThis as any).window = { electronAPI: {} };
|
|
34
|
+
const { getElectronIpc } = await import('./ipc');
|
|
35
|
+
|
|
36
|
+
expect(getElectronIpc()).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates a cached proxy and forwards payloads to invoke', async () => {
|
|
40
|
+
const invoke = vi.fn();
|
|
41
|
+
(globalThis as any).window = {
|
|
42
|
+
electronAPI: {
|
|
43
|
+
invoke,
|
|
44
|
+
onStreamInvoke: vi.fn(),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const { getElectronIpc } = await import('./ipc');
|
|
49
|
+
|
|
50
|
+
const ipc = getElectronIpc();
|
|
51
|
+
expect(ipc).not.toBeNull();
|
|
52
|
+
|
|
53
|
+
await (ipc as any).system.updateLocale('en-US');
|
|
54
|
+
expect(invoke).toHaveBeenCalledWith('system.updateLocale', 'en-US');
|
|
55
|
+
|
|
56
|
+
await (ipc as any).windows.closeWindow();
|
|
57
|
+
expect(invoke).toHaveBeenCalledWith('windows.closeWindow');
|
|
58
|
+
|
|
59
|
+
const cached = getElectronIpc();
|
|
60
|
+
expect(cached).toBe(ipc);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-interface */
|
|
2
|
+
import type { DispatchInvoke } from './types/dispatch';
|
|
3
|
+
import type { ProxyTRPCRequestParams } from './types/proxyTRPCRequest';
|
|
4
|
+
|
|
5
|
+
interface StreamerCallbacks {
|
|
6
|
+
onData: (chunk: Uint8Array) => void;
|
|
7
|
+
onEnd: () => void;
|
|
8
|
+
onError: (error: Error) => void;
|
|
9
|
+
onResponse: (response: {
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
status: number;
|
|
12
|
+
statusText: string;
|
|
13
|
+
}) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DesktopIpcServicesMap {}
|
|
17
|
+
export type DesktopIpcServices = DesktopIpcServicesMap;
|
|
18
|
+
export type ElectronDesktopIpc = DesktopIpcServices | null;
|
|
19
|
+
|
|
20
|
+
const createInvokeProxy = <IpcServices>(invoke: DispatchInvoke): IpcServices =>
|
|
21
|
+
new Proxy(
|
|
22
|
+
{},
|
|
23
|
+
{
|
|
24
|
+
get(_target, groupKey) {
|
|
25
|
+
if (typeof groupKey !== 'string') return undefined;
|
|
26
|
+
|
|
27
|
+
return new Proxy(
|
|
28
|
+
{},
|
|
29
|
+
{
|
|
30
|
+
get(_methodTarget, methodKey) {
|
|
31
|
+
if (typeof methodKey !== 'string') return undefined;
|
|
32
|
+
|
|
33
|
+
const channel = `${groupKey}.${methodKey}`;
|
|
34
|
+
return (payload?: unknown) =>
|
|
35
|
+
payload === undefined ? invoke(channel) : invoke(channel, payload);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
) as IpcServices;
|
|
42
|
+
|
|
43
|
+
let cachedProxy: DesktopIpcServices | null = null;
|
|
44
|
+
|
|
45
|
+
declare global {
|
|
46
|
+
interface Window {
|
|
47
|
+
electronAPI?: {
|
|
48
|
+
invoke?: DispatchInvoke;
|
|
49
|
+
onStreamInvoke: (params: ProxyTRPCRequestParams, callbacks: StreamerCallbacks) => () => void;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const getElectronIpc = (): DesktopIpcServices | null => {
|
|
55
|
+
if (typeof window === 'undefined') return null;
|
|
56
|
+
if (cachedProxy) return cachedProxy;
|
|
57
|
+
|
|
58
|
+
const invoke = window.electronAPI?.invoke;
|
|
59
|
+
if (!invoke) return null;
|
|
60
|
+
|
|
61
|
+
cachedProxy = createInvokeProxy<DesktopIpcServices>(invoke);
|
|
62
|
+
return cachedProxy;
|
|
63
|
+
};
|