@lobehub/chat 1.99.5 → 1.100.0

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.
Files changed (98) hide show
  1. package/.cursor/rules/code-review.mdc +38 -34
  2. package/.cursor/rules/system-role.mdc +8 -3
  3. package/.cursor/rules/testing-guide/testing-guide.mdc +155 -233
  4. package/.github/workflows/desktop-pr-build.yml +3 -3
  5. package/.github/workflows/release-desktop-beta.yml +3 -3
  6. package/CHANGELOG.md +50 -0
  7. package/apps/desktop/package.json +6 -3
  8. package/apps/desktop/src/main/controllers/AuthCtr.ts +310 -111
  9. package/apps/desktop/src/main/controllers/NetworkProxyCtr.ts +1 -1
  10. package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +50 -3
  11. package/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts +188 -23
  12. package/apps/desktop/src/main/controllers/__tests__/NetworkProxyCtr.test.ts +37 -18
  13. package/apps/desktop/src/main/types/store.ts +1 -0
  14. package/apps/desktop/src/preload/electronApi.ts +2 -1
  15. package/apps/desktop/src/preload/streamer.ts +58 -0
  16. package/changelog/v1.json +18 -0
  17. package/docs/development/database-schema.dbml +9 -0
  18. package/locales/ar/electron.json +3 -0
  19. package/locales/ar/oauth.json +8 -4
  20. package/locales/bg-BG/electron.json +3 -0
  21. package/locales/bg-BG/oauth.json +8 -4
  22. package/locales/de-DE/electron.json +3 -0
  23. package/locales/de-DE/oauth.json +9 -5
  24. package/locales/en-US/electron.json +3 -0
  25. package/locales/en-US/oauth.json +8 -4
  26. package/locales/es-ES/electron.json +3 -0
  27. package/locales/es-ES/oauth.json +9 -5
  28. package/locales/fa-IR/electron.json +3 -0
  29. package/locales/fa-IR/oauth.json +8 -4
  30. package/locales/fr-FR/electron.json +3 -0
  31. package/locales/fr-FR/oauth.json +8 -4
  32. package/locales/it-IT/electron.json +3 -0
  33. package/locales/it-IT/oauth.json +9 -5
  34. package/locales/ja-JP/electron.json +3 -0
  35. package/locales/ja-JP/oauth.json +8 -4
  36. package/locales/ko-KR/electron.json +3 -0
  37. package/locales/ko-KR/oauth.json +8 -4
  38. package/locales/nl-NL/electron.json +3 -0
  39. package/locales/nl-NL/oauth.json +9 -5
  40. package/locales/pl-PL/electron.json +3 -0
  41. package/locales/pl-PL/oauth.json +8 -4
  42. package/locales/pt-BR/electron.json +3 -0
  43. package/locales/pt-BR/oauth.json +8 -4
  44. package/locales/ru-RU/electron.json +3 -0
  45. package/locales/ru-RU/oauth.json +8 -4
  46. package/locales/tr-TR/electron.json +3 -0
  47. package/locales/tr-TR/oauth.json +8 -4
  48. package/locales/vi-VN/electron.json +3 -0
  49. package/locales/vi-VN/oauth.json +9 -5
  50. package/locales/zh-CN/electron.json +3 -0
  51. package/locales/zh-CN/oauth.json +8 -4
  52. package/locales/zh-TW/electron.json +3 -0
  53. package/locales/zh-TW/oauth.json +8 -4
  54. package/package.json +3 -3
  55. package/packages/electron-client-ipc/src/dispatch.ts +14 -2
  56. package/packages/electron-client-ipc/src/index.ts +1 -0
  57. package/packages/electron-client-ipc/src/streamInvoke.ts +62 -0
  58. package/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts +5 -0
  59. package/packages/electron-client-ipc/src/utils/headers.ts +27 -0
  60. package/packages/electron-client-ipc/src/utils/request.ts +28 -0
  61. package/src/app/(backend)/oidc/callback/desktop/route.ts +58 -0
  62. package/src/app/(backend)/oidc/handoff/route.ts +46 -0
  63. package/src/app/[variants]/oauth/callback/error/page.tsx +55 -0
  64. package/src/app/[variants]/oauth/callback/layout.tsx +12 -0
  65. package/src/app/[variants]/oauth/callback/loading.tsx +3 -0
  66. package/src/app/[variants]/oauth/{consent/[uid] → callback}/success/page.tsx +10 -1
  67. package/src/app/[variants]/oauth/consent/[uid]/Consent.tsx +7 -1
  68. package/src/database/client/migrations.json +8 -0
  69. package/src/database/migrations/0028_oauth_handoffs.sql +8 -0
  70. package/src/database/migrations/meta/0028_snapshot.json +6055 -0
  71. package/src/database/migrations/meta/_journal.json +7 -0
  72. package/src/database/models/oauthHandoff.ts +94 -0
  73. package/src/database/repositories/tableViewer/index.test.ts +1 -1
  74. package/src/database/schemas/oidc.ts +46 -0
  75. package/src/features/ElectronTitlebar/Connection/Waiting.tsx +59 -115
  76. package/src/features/ElectronTitlebar/Connection/WaitingAnim.tsx +114 -0
  77. package/src/libs/oidc-provider/config.ts +16 -17
  78. package/src/libs/oidc-provider/jwt.ts +135 -0
  79. package/src/libs/oidc-provider/provider.ts +22 -38
  80. package/src/libs/trpc/client/async.ts +1 -2
  81. package/src/libs/trpc/client/edge.ts +1 -2
  82. package/src/libs/trpc/client/lambda.ts +1 -1
  83. package/src/libs/trpc/client/tools.ts +1 -2
  84. package/src/libs/trpc/lambda/context.ts +9 -16
  85. package/src/locales/default/electron.ts +3 -0
  86. package/src/locales/default/oauth.ts +8 -4
  87. package/src/middleware.ts +10 -4
  88. package/src/server/services/oidc/index.ts +0 -71
  89. package/src/services/__tests__/chat.test.ts +998 -62
  90. package/src/services/chat.ts +109 -59
  91. package/src/services/electron/remoteServer.ts +0 -7
  92. package/src/{libs/trpc/client/helpers → utils/electron}/desktopRemoteRPCFetch.ts +22 -7
  93. package/src/utils/server/auth.ts +22 -0
  94. package/src/utils/url.test.ts +42 -1
  95. package/src/utils/url.ts +28 -0
  96. package/src/app/[variants]/oauth/consent/[uid]/failed/page.tsx +0 -36
  97. package/src/app/[variants]/oauth/handoff/Client.tsx +0 -98
  98. package/src/app/[variants]/oauth/handoff/page.tsx +0 -13
@@ -79,6 +79,12 @@ export default class RemoteServerConfigCtr extends ControllerModule {
79
79
  private encryptedAccessToken?: string;
80
80
  private encryptedRefreshToken?: string;
81
81
 
82
+ /**
83
+ * Token expiration time (timestamp in milliseconds)
84
+ * Used for automatic token refresh
85
+ */
86
+ private tokenExpiresAt?: number;
87
+
82
88
  /**
83
89
  * Promise representing the ongoing token refresh operation.
84
90
  * Used to prevent concurrent refreshes and allow callers to wait.
@@ -89,10 +95,19 @@ export default class RemoteServerConfigCtr extends ControllerModule {
89
95
  * Encrypt and store tokens
90
96
  * @param accessToken Access token
91
97
  * @param refreshToken Refresh token
98
+ * @param expiresIn Token expiration time in seconds (optional)
92
99
  */
93
- async saveTokens(accessToken: string, refreshToken: string) {
100
+ async saveTokens(accessToken: string, refreshToken: string, expiresIn?: number) {
94
101
  logger.info('Saving encrypted tokens');
95
102
 
103
+ // Calculate expiration time if provided
104
+ if (expiresIn) {
105
+ this.tokenExpiresAt = Date.now() + expiresIn * 1000;
106
+ logger.debug(`Token expires at: ${new Date(this.tokenExpiresAt).toISOString()}`);
107
+ } else {
108
+ this.tokenExpiresAt = undefined;
109
+ }
110
+
96
111
  // If platform doesn't support secure storage, store raw tokens
97
112
  if (!safeStorage.isEncryptionAvailable()) {
98
113
  logger.warn('Safe storage not available, storing tokens unencrypted');
@@ -101,6 +116,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
101
116
  // Persist unencrypted tokens (consider security implications)
102
117
  this.app.storeManager.set(this.encryptedTokensKey, {
103
118
  accessToken: this.encryptedAccessToken,
119
+ expiresAt: this.tokenExpiresAt,
104
120
  refreshToken: this.encryptedRefreshToken,
105
121
  });
106
122
  return;
@@ -120,6 +136,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
120
136
  logger.debug(`Persisting encrypted tokens to store key: ${this.encryptedTokensKey}`);
121
137
  this.app.storeManager.set(this.encryptedTokensKey, {
122
138
  accessToken: this.encryptedAccessToken,
139
+ expiresAt: this.tokenExpiresAt,
123
140
  refreshToken: this.encryptedRefreshToken,
124
141
  });
125
142
  }
@@ -199,17 +216,40 @@ export default class RemoteServerConfigCtr extends ControllerModule {
199
216
  logger.info('Clearing access and refresh tokens');
200
217
  this.encryptedAccessToken = undefined;
201
218
  this.encryptedRefreshToken = undefined;
219
+ this.tokenExpiresAt = undefined;
202
220
  // Also clear from persistent storage
203
221
  logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`);
204
222
  this.app.storeManager.delete(this.encryptedTokensKey);
205
223
  }
206
224
 
225
+ /**
226
+ * Get token expiration time
227
+ */
228
+ getTokenExpiresAt(): number | undefined {
229
+ return this.tokenExpiresAt;
230
+ }
231
+
232
+ /**
233
+ * Check if token is expired or will expire soon
234
+ * @param bufferTimeMs Buffer time in milliseconds (default 5 minutes)
235
+ * @returns true if token is expired or will expire soon
236
+ */
237
+ isTokenExpiringSoon(bufferTimeMs: number = 5 * 60 * 1000): boolean {
238
+ if (!this.tokenExpiresAt) {
239
+ return false; // No expiration time available
240
+ }
241
+
242
+ const currentTime = Date.now();
243
+ const bufferTime = this.tokenExpiresAt - bufferTimeMs;
244
+
245
+ return currentTime >= bufferTime;
246
+ }
247
+
207
248
  /**
208
249
  * 刷新访问令牌
209
250
  * 使用存储的刷新令牌获取新的访问令牌
210
251
  * Handles concurrent requests by returning the existing refresh promise if one is in progress.
211
252
  */
212
- @ipcClientEvent('refreshAccessToken')
213
253
  async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
214
254
  // If a refresh is already in progress, return the existing promise
215
255
  if (this.refreshPromise) {
@@ -290,7 +330,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
290
330
 
291
331
  // 保存新令牌
292
332
  logger.info('Token refresh successful, saving new tokens.');
293
- await this.saveTokens(data.access_token, data.refresh_token);
333
+ await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
294
334
 
295
335
  return { success: true };
296
336
  } catch (error) {
@@ -316,6 +356,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
316
356
  logger.info('Successfully loaded tokens from store into memory.');
317
357
  this.encryptedAccessToken = storedTokens.accessToken;
318
358
  this.encryptedRefreshToken = storedTokens.refreshToken;
359
+ this.tokenExpiresAt = storedTokens.expiresAt;
360
+
361
+ if (this.tokenExpiresAt) {
362
+ logger.debug(
363
+ `Loaded token expiration time: ${new Date(this.tokenExpiresAt).toISOString()}`,
364
+ );
365
+ }
319
366
  } else {
320
367
  logger.debug('No valid tokens found in store.');
321
368
  }
@@ -1,12 +1,17 @@
1
1
  import {
2
2
  ProxyTRPCRequestParams,
3
3
  ProxyTRPCRequestResult,
4
- } from '@lobechat/electron-client-ipc/src/types/proxyTRPCRequest';
4
+ ProxyTRPCStreamRequestParams,
5
+ } from '@lobechat/electron-client-ipc';
6
+ import { IpcMainEvent, WebContents, ipcMain } from 'electron';
7
+ import { HttpProxyAgent } from 'http-proxy-agent';
8
+ import { HttpsProxyAgent } from 'https-proxy-agent';
5
9
  import { Buffer } from 'node:buffer';
6
10
  import http, { IncomingMessage, OutgoingHttpHeaders } from 'node:http';
7
11
  import https from 'node:https';
8
12
  import { URL } from 'node:url';
9
13
 
14
+ import { defaultProxySettings } from '@/const/store';
10
15
  import { createLogger } from '@/utils/logger';
11
16
 
12
17
  import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -41,6 +46,137 @@ export default class RemoteServerSyncCtr extends ControllerModule {
41
46
  afterAppReady() {
42
47
  logger.info('RemoteServerSyncCtr initialized (IPC based)');
43
48
  // No need to register protocol handler anymore
49
+ ipcMain.on('stream:start', this.handleStreamRequest);
50
+ }
51
+
52
+ /**
53
+ * 处理流式请求的 IPC 调用
54
+ */
55
+ private handleStreamRequest = async (event: IpcMainEvent, args: ProxyTRPCStreamRequestParams) => {
56
+ const { requestId } = args;
57
+ const logPrefix = `[StreamProxy ${args.method} ${args.urlPath}][${requestId}]`;
58
+ logger.debug(`${logPrefix} Received stream:start IPC call`);
59
+
60
+ try {
61
+ const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
62
+ if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
63
+ logger.warn(`${logPrefix} Remote server sync not active or configured.`);
64
+ event.sender.send(
65
+ `stream:error:${requestId}`,
66
+ new Error('Remote server sync not active or configured'),
67
+ );
68
+ return;
69
+ }
70
+
71
+ const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
72
+ const token = await this.remoteServerConfigCtr.getAccessToken();
73
+
74
+ if (!token) {
75
+ // 401 Unauthorized
76
+ event.sender.send(`stream:response:${requestId}`, {
77
+ headers: {},
78
+ status: 401,
79
+ statusText: 'Authentication required, missing token',
80
+ });
81
+ event.sender.send(`stream:end:${requestId}`);
82
+ return;
83
+ }
84
+
85
+ // 调用新的流式转发方法
86
+ await this.forwardStreamRequest(event.sender, {
87
+ ...args,
88
+ accessToken: token,
89
+ remoteServerUrl,
90
+ });
91
+ } catch (error) {
92
+ logger.error(`${logPrefix} Unhandled error processing stream request:`, error);
93
+ event.sender.send(
94
+ `stream:error:${requestId}`,
95
+ error instanceof Error ? error : new Error('Unknown error'),
96
+ );
97
+ }
98
+ };
99
+
100
+ /**
101
+ * 执行实际的流式请求转发
102
+ */
103
+ private async forwardStreamRequest(
104
+ sender: WebContents,
105
+ args: ProxyTRPCStreamRequestParams & { accessToken: string; remoteServerUrl: string },
106
+ ) {
107
+ const {
108
+ urlPath,
109
+ method,
110
+ headers: originalHeaders,
111
+ body: requestBody,
112
+ accessToken,
113
+ remoteServerUrl,
114
+ requestId,
115
+ } = args;
116
+ const targetUrl = new URL(urlPath, remoteServerUrl);
117
+ const logPrefix = `[ForwardStream ${method} ${targetUrl.pathname}][${requestId}]`;
118
+
119
+ const { requestOptions, requester } = this.createRequester({
120
+ accessToken,
121
+ headers: originalHeaders,
122
+ method,
123
+ url: targetUrl,
124
+ });
125
+
126
+ const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
127
+ logger.debug(`${logPrefix} Received response with status ${clientRes.statusCode}`);
128
+
129
+ // 添加调试信息
130
+ logger.debug(`${logPrefix} Response details:`, {
131
+ headers: clientRes.headers,
132
+ statusCode: clientRes.statusCode,
133
+ statusMessage: clientRes.statusMessage,
134
+ });
135
+
136
+ // 1. 立刻发送响应头和状态码
137
+ const responseData = {
138
+ headers: clientRes.headers || {},
139
+ status: clientRes.statusCode || 500,
140
+ statusText: clientRes.statusMessage || 'Unknown Status',
141
+ };
142
+
143
+ logger.debug(`${logPrefix} Sending response data:`, responseData);
144
+ sender.send(`stream:response:${requestId}`, responseData);
145
+
146
+ // 2. 监听数据块并转发
147
+ clientRes.on('data', (chunk: Buffer) => {
148
+ if (sender.isDestroyed()) return;
149
+ logger.debug(`${logPrefix} Received data chunk, size: ${chunk.length}. Forwarding...`);
150
+ sender.send(`stream:data:${requestId}`, chunk);
151
+ });
152
+
153
+ // 3. 监听结束信号并转发
154
+ clientRes.on('end', () => {
155
+ logger.debug(`${logPrefix} Stream ended. Forwarding end signal...`);
156
+ if (sender.isDestroyed()) return;
157
+ sender.send(`stream:end:${requestId}`);
158
+ });
159
+
160
+ // 4. 监听响应流错误并转发
161
+ clientRes.on('error', (error) => {
162
+ logger.error(`${logPrefix} Error reading response stream:`, error);
163
+ if (sender.isDestroyed()) return;
164
+ sender.send(`stream:error:${requestId}`, error);
165
+ });
166
+ });
167
+
168
+ // 5. 监听请求本身的错误(如 DNS 解析失败)
169
+ clientReq.on('error', (error) => {
170
+ logger.error(`${logPrefix} Error forwarding request:`, error);
171
+ if (sender.isDestroyed()) return;
172
+ sender.send(`stream:error:${requestId}`, error);
173
+ });
174
+
175
+ if (requestBody) {
176
+ clientReq.write(Buffer.from(requestBody));
177
+ }
178
+
179
+ clientReq.end();
44
180
  }
45
181
 
46
182
  /**
@@ -85,28 +221,12 @@ export default class RemoteServerSyncCtr extends ControllerModule {
85
221
 
86
222
  // 1. Determine target URL and prepare request options
87
223
  const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
88
-
89
- // Prepare headers, cloning and adding Authorization
90
- const requestHeaders: OutgoingHttpHeaders = { ...originalHeaders }; // Use OutgoingHttpHeaders
91
- requestHeaders['Authorization'] = `Bearer ${accessToken}`;
92
-
93
- // Let node handle Host, Content-Length etc. Remove potentially problematic headers
94
- delete requestHeaders['host'];
95
- delete requestHeaders['connection']; // Often causes issues
96
- // delete requestHeaders['content-length']; // Let node handle it based on body
97
-
98
- const requestOptions: https.RequestOptions | http.RequestOptions = {
99
- // Use union type
100
- headers: requestHeaders,
101
- hostname: targetUrl.hostname,
102
- method: method,
103
- path: targetUrl.pathname + targetUrl.search,
104
- port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
105
- protocol: targetUrl.protocol,
106
- // agent: false, // Consider for keep-alive issues if they arise
107
- };
108
-
109
- const requester = targetUrl.protocol === 'https:' ? https : http;
224
+ const { requestOptions, requester } = this.createRequester({
225
+ accessToken,
226
+ headers: originalHeaders,
227
+ method,
228
+ url: targetUrl,
229
+ });
110
230
 
111
231
  // 2. Make the request and capture response
112
232
  return new Promise((resolve) => {
@@ -176,6 +296,51 @@ export default class RemoteServerSyncCtr extends ControllerModule {
176
296
  });
177
297
  }
178
298
 
299
+ private createRequester({
300
+ headers,
301
+ accessToken,
302
+ method,
303
+ url,
304
+ }: {
305
+ accessToken: string;
306
+ headers: Record<string, string>;
307
+ method: string;
308
+ url: URL;
309
+ }) {
310
+ // Prepare headers, cloning and adding Oidc-Auth
311
+ const requestHeaders: OutgoingHttpHeaders = { ...headers }; // Use OutgoingHttpHeaders
312
+ requestHeaders['Oidc-Auth'] = accessToken;
313
+
314
+ // Let node handle Host, Content-Length etc. Remove potentially problematic headers
315
+ delete requestHeaders['host'];
316
+ delete requestHeaders['connection']; // Often causes issues
317
+ // delete requestHeaders['content-length']; // Let node handle it based on body
318
+
319
+ // 读取代理配置
320
+ const proxyConfig = this.app.storeManager.get('networkProxy', defaultProxySettings);
321
+
322
+ let agent;
323
+ if (proxyConfig?.enableProxy && proxyConfig.proxyServer) {
324
+ const proxyUrl = `${proxyConfig.proxyType}://${proxyConfig.proxyServer}${proxyConfig.proxyPort ? `:${proxyConfig.proxyPort}` : ''}`;
325
+ agent =
326
+ url.protocol === 'https:' ? new HttpsProxyAgent(proxyUrl) : new HttpProxyAgent(proxyUrl);
327
+ }
328
+
329
+ const requestOptions: https.RequestOptions | http.RequestOptions = {
330
+ agent,
331
+ // Use union type
332
+ headers: requestHeaders,
333
+ hostname: url.hostname,
334
+ method: method,
335
+ path: url.pathname + url.search,
336
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
337
+ protocol: url.protocol,
338
+ };
339
+
340
+ const requester = url.protocol === 'https:' ? https : http;
341
+ return { requestOptions, requester };
342
+ }
343
+
179
344
  /**
180
345
  * Handles the 'proxy-trpc-request' IPC call from the renderer process.
181
346
  * This method should be invoked by the ipcMain.handle setup in your main process entry point.
@@ -15,11 +15,12 @@ vi.mock('@/utils/logger', () => ({
15
15
  }),
16
16
  }));
17
17
 
18
- // 模拟 undici
18
+ // 模拟 undici - 使用 vi.fn() 直接在 Mock 中创建
19
19
  vi.mock('undici', () => ({
20
20
  fetch: vi.fn(),
21
21
  getGlobalDispatcher: vi.fn(),
22
22
  setGlobalDispatcher: vi.fn(),
23
+ Agent: vi.fn(),
23
24
  ProxyAgent: vi.fn(),
24
25
  }));
25
26
 
@@ -35,9 +36,6 @@ vi.mock('@/const/store', () => ({
35
36
  },
36
37
  }));
37
38
 
38
- // 模拟 fetch
39
- global.fetch = vi.fn();
40
-
41
39
  // 模拟 App 及其依赖项
42
40
  const mockStoreManager = {
43
41
  get: vi.fn(),
@@ -51,12 +49,31 @@ const mockApp = {
51
49
  describe('NetworkProxyCtr', () => {
52
50
  let networkProxyCtr: NetworkProxyCtr;
53
51
 
54
- beforeEach(() => {
52
+ // 动态导入 undici 的 Mock
53
+ let mockUndici: any;
54
+
55
+ beforeEach(async () => {
55
56
  vi.clearAllMocks();
57
+
58
+ // 动态导入 undici Mock
59
+ mockUndici = await import('undici');
60
+
56
61
  networkProxyCtr = new NetworkProxyCtr(mockApp);
57
62
 
58
- // 重置全局 fetch mock
59
- (global.fetch as any).mockReset();
63
+ // 设置 undici mocks 的默认返回值
64
+ vi.mocked(mockUndici.Agent).mockReturnValue({});
65
+ vi.mocked(mockUndici.ProxyAgent).mockReturnValue({});
66
+ vi.mocked(mockUndici.getGlobalDispatcher).mockReturnValue({
67
+ destroy: vi.fn().mockResolvedValue(undefined),
68
+ });
69
+ vi.mocked(mockUndici.setGlobalDispatcher).mockReturnValue(undefined);
70
+
71
+ // 设置 fetch mock 的默认返回值
72
+ vi.mocked(mockUndici.fetch).mockResolvedValue({
73
+ ok: true,
74
+ status: 200,
75
+ statusText: 'OK',
76
+ });
60
77
  });
61
78
 
62
79
  describe('ProxyConfigValidator', () => {
@@ -213,12 +230,12 @@ describe('NetworkProxyCtr', () => {
213
230
  statusText: 'OK',
214
231
  };
215
232
 
216
- (global.fetch as any).mockResolvedValueOnce(mockResponse);
233
+ vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
217
234
 
218
235
  const result = await networkProxyCtr.testProxyConnection('https://www.google.com');
219
236
 
220
237
  expect(result).toEqual({ success: true });
221
- expect(global.fetch).toHaveBeenCalledWith('https://www.google.com', expect.any(Object));
238
+ expect(mockUndici.fetch).toHaveBeenCalledWith('https://www.google.com', expect.any(Object));
222
239
  });
223
240
 
224
241
  it('should throw error for failed connection', async () => {
@@ -228,13 +245,13 @@ describe('NetworkProxyCtr', () => {
228
245
  statusText: 'Not Found',
229
246
  };
230
247
 
231
- (global.fetch as any).mockResolvedValueOnce(mockResponse);
248
+ vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
232
249
 
233
250
  await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
234
251
  });
235
252
 
236
253
  it('should throw error for network error', async () => {
237
- (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
254
+ vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Network error'));
238
255
 
239
256
  await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
240
257
  });
@@ -257,7 +274,7 @@ describe('NetworkProxyCtr', () => {
257
274
  statusText: 'OK',
258
275
  };
259
276
 
260
- (global.fetch as any).mockResolvedValueOnce(mockResponse);
277
+ vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
261
278
 
262
279
  const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
263
280
 
@@ -289,7 +306,7 @@ describe('NetworkProxyCtr', () => {
289
306
  statusText: 'OK',
290
307
  };
291
308
 
292
- (global.fetch as any).mockResolvedValueOnce(mockResponse);
309
+ vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
293
310
 
294
311
  const result = await networkProxyCtr.testProxyConfig({ config: disabledConfig });
295
312
 
@@ -297,7 +314,7 @@ describe('NetworkProxyCtr', () => {
297
314
  });
298
315
 
299
316
  it('should return failure for connection error', async () => {
300
- (global.fetch as any).mockRejectedValueOnce(new Error('Connection failed'));
317
+ vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Connection failed'));
301
318
 
302
319
  const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
303
320
 
@@ -306,7 +323,7 @@ describe('NetworkProxyCtr', () => {
306
323
  });
307
324
  });
308
325
 
309
- describe('afterAppReady', () => {
326
+ describe('beforeAppReady', () => {
310
327
  it('should apply stored proxy settings on app ready', async () => {
311
328
  const storedConfig: NetworkProxySettings = {
312
329
  enableProxy: true,
@@ -319,7 +336,7 @@ describe('NetworkProxyCtr', () => {
319
336
 
320
337
  mockStoreManager.get.mockReturnValue(storedConfig);
321
338
 
322
- await networkProxyCtr.afterAppReady();
339
+ await networkProxyCtr.beforeAppReady();
323
340
 
324
341
  expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
325
342
  });
@@ -336,7 +353,7 @@ describe('NetworkProxyCtr', () => {
336
353
 
337
354
  mockStoreManager.get.mockReturnValue(invalidConfig);
338
355
 
339
- await networkProxyCtr.afterAppReady();
356
+ await networkProxyCtr.beforeAppReady();
340
357
 
341
358
  expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
342
359
  });
@@ -347,7 +364,9 @@ describe('NetworkProxyCtr', () => {
347
364
  });
348
365
 
349
366
  // 不应该抛出错误
350
- await expect(networkProxyCtr.afterAppReady()).resolves.not.toThrow();
367
+ await expect(networkProxyCtr.beforeAppReady()).resolves.not.toThrow();
368
+
369
+ mockStoreManager.get.mockReset();
351
370
  });
352
371
  });
353
372
 
@@ -4,6 +4,7 @@ export interface ElectronMainStore {
4
4
  dataSyncConfig: DataSyncConfig;
5
5
  encryptedTokens: {
6
6
  accessToken?: string;
7
+ expiresAt?: number;
7
8
  refreshToken?: string;
8
9
  };
9
10
  locale: string;
@@ -2,6 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload';
2
2
  import { contextBridge } from 'electron';
3
3
 
4
4
  import { invoke } from './invoke';
5
+ import { onStreamInvoke } from './streamer';
5
6
 
6
7
  export const setupElectronApi = () => {
7
8
  // Use `contextBridge` APIs to expose Electron APIs to
@@ -14,5 +15,5 @@ export const setupElectronApi = () => {
14
15
  console.error(error);
15
16
  }
16
17
 
17
- contextBridge.exposeInMainWorld('electronAPI', { invoke });
18
+ contextBridge.exposeInMainWorld('electronAPI', { invoke, onStreamInvoke });
18
19
  };
@@ -0,0 +1,58 @@
1
+ import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
2
+ import { ipcRenderer } from 'electron';
3
+ import { v4 as uuid } from 'uuid';
4
+
5
+ interface StreamResponse {
6
+ headers: Record<string, string>;
7
+ status: number;
8
+ statusText: string;
9
+ }
10
+
11
+ export interface StreamerCallbacks {
12
+ onData: (chunk: Uint8Array) => void;
13
+ onEnd: () => void;
14
+ onError: (error: Error) => void;
15
+ onResponse: (response: StreamResponse) => void;
16
+ }
17
+
18
+ /**
19
+ * Calls the main process method and handles the stream response via callbacks.
20
+ * @param params The request parameters.
21
+ * @param callbacks The callbacks to handle stream events.
22
+ */
23
+ export const onStreamInvoke = (
24
+ params: ProxyTRPCRequestParams,
25
+ callbacks: StreamerCallbacks,
26
+ ): (() => void) => {
27
+ const requestId = uuid();
28
+
29
+ const cleanup = () => {
30
+ ipcRenderer.removeAllListeners(`stream:data:${requestId}`);
31
+ ipcRenderer.removeAllListeners(`stream:end:${requestId}`);
32
+ ipcRenderer.removeAllListeners(`stream:error:${requestId}`);
33
+ ipcRenderer.removeAllListeners(`stream:response:${requestId}`);
34
+ };
35
+
36
+ ipcRenderer.on(`stream:data:${requestId}`, (_, chunk: Buffer) => {
37
+ callbacks.onData(new Uint8Array(chunk));
38
+ });
39
+
40
+ ipcRenderer.once(`stream:end:${requestId}`, () => {
41
+ callbacks.onEnd();
42
+ cleanup();
43
+ });
44
+
45
+ ipcRenderer.once(`stream:error:${requestId}`, (_, error: Error) => {
46
+ callbacks.onError(error);
47
+ cleanup();
48
+ });
49
+
50
+ ipcRenderer.once(`stream:response:${requestId}`, (_, response: StreamResponse) => {
51
+ callbacks.onResponse(response);
52
+ });
53
+
54
+ ipcRenderer.send('stream:start', { ...params, requestId });
55
+
56
+ // Return a cleanup function to be called on cancellation
57
+ return cleanup;
58
+ };
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "features": [
5
+ "Refactor desktop oauth and use JWTs token to support remote chat."
6
+ ]
7
+ },
8
+ "date": "2025-07-17",
9
+ "version": "1.100.0"
10
+ },
11
+ {
12
+ "children": {
13
+ "fixes": [
14
+ "Desktop local db can't upload image."
15
+ ]
16
+ },
17
+ "date": "2025-07-16",
18
+ "version": "1.99.6"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "fixes": [
@@ -431,6 +431,15 @@ table nextauth_verificationtokens {
431
431
  }
432
432
  }
433
433
 
434
+ table oauth_handoffs {
435
+ id text [pk, not null]
436
+ client varchar(50) [not null]
437
+ payload jsonb [not null]
438
+ accessed_at "timestamp with time zone" [not null, default: `now()`]
439
+ created_at "timestamp with time zone" [not null, default: `now()`]
440
+ updated_at "timestamp with time zone" [not null, default: `now()`]
441
+ }
442
+
434
443
  table oidc_access_tokens {
435
444
  id varchar(255) [pk, not null]
436
445
  data jsonb [not null]
@@ -101,7 +101,10 @@
101
101
  "waitingOAuth": {
102
102
  "cancel": "إلغاء",
103
103
  "description": "تم فتح صفحة التفويض في المتصفح، يرجى إكمال التفويض في المتصفح",
104
+ "error": "فشل التفويض: {{error}}",
105
+ "errorTitle": "فشل في الاتصال بالتفويض",
104
106
  "helpText": "إذا لم يفتح المتصفح تلقائيًا، يرجى النقر على إلغاء ثم المحاولة مرة أخرى",
107
+ "retry": "أعد المحاولة",
105
108
  "title": "انتظار الاتصال بالتفويض"
106
109
  }
107
110
  }
@@ -28,10 +28,14 @@
28
28
  },
29
29
  "title": "تفويض {{clientName}}"
30
30
  },
31
- "failed": {
31
+ "error": {
32
32
  "backToHome": "العودة إلى الصفحة الرئيسية",
33
- "subTitle": "لقد رفضت منح التطبيق الوصول إلى حسابك في LobeChat",
34
- "title": "تم رفض التفويض"
33
+ "desc": "فشل تفويض OAuth، سبب الفشل: {{reason}}",
34
+ "reason": {
35
+ "internal_error": "خطأ في الخادم",
36
+ "invalid_request": "معلمات طلب غير صالحة"
37
+ },
38
+ "title": "فشل التفويض"
35
39
  },
36
40
  "handoff": {
37
41
  "desc": {
@@ -50,7 +54,7 @@
50
54
  "userWelcome": "مرحبًا بعودتك،"
51
55
  },
52
56
  "success": {
53
- "subTitle": "لقد منحت التطبيق الوصول إلى حسابك في LobeChat بنجاح، يمكنك إغلاق هذه الصفحة الآن",
57
+ "subTitle": "لقد قمت بتفويض التطبيق للوصول إلى حسابك بنجاح، يمكنك الآن إغلاق هذه الصفحة",
54
58
  "title": "تم التفويض بنجاح"
55
59
  }
56
60
  }
@@ -101,7 +101,10 @@
101
101
  "waitingOAuth": {
102
102
  "cancel": "Отмени",
103
103
  "description": "Браузърът е отворил страницата за авторизация, моля, завършете авторизацията в браузъра",
104
+ "error": "Неуспешно упълномощаване: {{error}}",
105
+ "errorTitle": "Неуспешно свързване за упълномощаване",
104
106
  "helpText": "Ако браузърът не се е отворил автоматично, моля, кликнете върху отмяна и опитайте отново",
107
+ "retry": "Опитай отново",
105
108
  "title": "Изчакване на авторизационна връзка"
106
109
  }
107
110
  }