@lobehub/lobehub 2.0.0-next.209 → 2.0.0-next.210

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 (26) hide show
  1. package/.vscode/settings.json +2 -17
  2. package/CHANGELOG.md +25 -0
  3. package/apps/desktop/src/main/controllers/SystemCtr.ts +10 -0
  4. package/apps/desktop/src/main/core/App.ts +10 -188
  5. package/apps/desktop/src/main/core/__tests__/App.test.ts +6 -42
  6. package/apps/desktop/src/main/core/browser/Browser.ts +17 -9
  7. package/apps/desktop/src/main/core/infrastructure/RendererUrlManager.ts +126 -0
  8. package/apps/desktop/src/main/core/infrastructure/__tests__/RendererUrlManager.test.ts +72 -0
  9. package/changelog/v1.json +5 -0
  10. package/package.json +1 -1
  11. package/packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx +1 -1
  12. package/packages/desktop-bridge/src/index.ts +0 -2
  13. package/packages/desktop-bridge/src/routeVariants.ts +0 -2
  14. package/packages/electron-client-ipc/src/types/system.ts +1 -0
  15. package/packages/model-bank/src/aiModels/lobehub.ts +0 -3
  16. package/packages/model-runtime/src/core/streams/openai/openai.test.ts +167 -0
  17. package/packages/model-runtime/src/core/streams/openai/openai.ts +30 -6
  18. package/packages/model-runtime/src/core/streams/protocol.ts +5 -0
  19. package/packages/model-runtime/src/core/streams/qwen.test.ts +131 -2
  20. package/packages/model-runtime/src/core/streams/qwen.ts +9 -1
  21. package/scripts/electronWorkflow/modifiers/index.mts +2 -0
  22. package/scripts/electronWorkflow/modifiers/nextConfig.mts +1 -1
  23. package/scripts/electronWorkflow/modifiers/staticExport.mts +174 -0
  24. package/src/layout/GlobalProvider/Locale.tsx +1 -1
  25. package/src/store/electron/actions/app.ts +6 -0
  26. package/src/utils/server/routeVariants.ts +2 -2
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "npm.packageManager": "pnpm",
28
28
  "search.exclude": {
29
- "**/node_modules": true,
29
+ "**/node_modules": true
30
30
  // useless to search this big folder
31
- "locales": true
31
+ // "locales": true
32
32
  },
33
33
  "stylelint.validate": [
34
34
  "css",
@@ -41,58 +41,43 @@
41
41
  "**/app/**/[[]*[]]/[[]*[]]/page.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • page component",
42
42
  "**/app/**/[[]*[]]/page.tsx": "${dirname(1)}/${dirname} • page component",
43
43
  "**/app/**/page.tsx": "${dirname} • page component",
44
-
45
44
  "**/app/**/[[]*[]]/[[]*[]]/layout.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • page layout",
46
45
  "**/app/**/[[]*[]]/layout.tsx": "${dirname(1)}/${dirname} • page layout",
47
46
  "**/app/**/layout.tsx": "${dirname} • page layout",
48
-
49
47
  "**/app/**/[[]*[]]/[[]*[]]/default.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • slot default",
50
48
  "**/app/**/[[]*[]]/default.tsx": "${dirname(1)}/${dirname} • slot default",
51
49
  "**/app/**/default.tsx": "${dirname} • slot default",
52
-
53
50
  "**/app/**/[[]*[]]/[[]*[]]/error.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • error component",
54
51
  "**/app/**/[[]*[]]/error.tsx": "${dirname(1)}/${dirname} • error component",
55
52
  "**/app/**/error.tsx": "${dirname} • error component",
56
-
57
53
  "**/app/**/[[]*[]]/[[]*[]]/loading.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • loading component",
58
54
  "**/app/**/[[]*[]]/loading.tsx": "${dirname(1)}/${dirname} • loading component",
59
55
  "**/app/**/loading.tsx": "${dirname} • loading component",
60
-
61
56
  "**/src/**/route.ts": "${dirname(1)}/${dirname} • route",
62
57
  "**/src/**/index.tsx": "${dirname} • component",
63
-
64
58
  "**/packages/database/src/repositories/*/index.ts": "${dirname} • db repository",
65
59
  "**/packages/database/src/models/*.ts": "${filename} • db model",
66
60
  "**/packages/database/src/schemas/*.ts": "${filename} • db schema",
67
-
68
61
  "**/src/services/*.ts": "${filename} • service",
69
62
  "**/src/services/*/client.ts": "${dirname} • client service",
70
63
  "**/src/services/*/server.ts": "${dirname} • server service",
71
-
72
64
  "**/src/store/*/action.ts": "${dirname} • action",
73
65
  "**/src/store/*/slices/*/action.ts": "${dirname(2)}/${dirname} • action",
74
66
  "**/src/store/*/slices/*/actions/*.ts": "${dirname(1)}/${dirname}/${filename} • action",
75
-
76
67
  "**/src/store/*/initialState.ts": "${dirname} • state",
77
68
  "**/src/store/*/slices/*/initialState.ts": "${dirname(2)}/${dirname} • state",
78
-
79
69
  "**/src/store/*/selectors.ts": "${dirname} • selectors",
80
70
  "**/src/store/*/slices/*/selectors.ts": "${dirname(2)}/${dirname} • selectors",
81
-
82
71
  "**/src/store/*/reducer.ts": "${dirname} • reducer",
83
72
  "**/src/store/*/slices/*/reducer.ts": "${dirname(2)}/${dirname} • reducer",
84
-
85
73
  "**/src/config/modelProviders/*.ts": "${filename} • provider",
86
74
  "**/packages/model-bank/src/aiModels/*.ts": "${filename} • model",
87
75
  "**/packages/model-runtime/src/providers/*/index.ts": "${dirname} • runtime",
88
-
89
76
  "**/src/server/services/*/index.ts": "${dirname} • server/service",
90
77
  "**/src/server/routers/lambda/*.ts": "${filename} • lambda",
91
78
  "**/src/server/routers/async/*.ts": "${filename} • async",
92
79
  "**/src/server/routers/edge/*.ts": "${filename} • edge",
93
-
94
80
  "**/src/locales/default/*.ts": "${filename} • locale",
95
-
96
81
  "**/index.*": "${dirname}/${filename}.${extname}"
97
82
  }
98
83
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.210](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.209...v2.0.0-next.210)
6
+
7
+ <sup>Released on **2026-01-04**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **model-runtime**: Handle Qwen tool_calls without initial arguments.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **model-runtime**: Handle Qwen tool_calls without initial arguments, closes [#11211](https://github.com/lobehub/lobe-chat/issues/11211) ([5321d91](https://github.com/lobehub/lobe-chat/commit/5321d91))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ## [Version 2.0.0-next.209](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.208...v2.0.0-next.209)
6
31
 
7
32
  <sup>Released on **2026-01-04**</sup>
@@ -38,6 +38,8 @@ export default class SystemController extends ControllerModule {
38
38
  isLinux: platform === 'linux',
39
39
  isMac: platform === 'darwin',
40
40
  isWindows: platform === 'win32',
41
+ locale: this.app.storeManager.get('locale', 'auto'),
42
+
41
43
  platform: platform as 'darwin' | 'win32' | 'linux',
42
44
  userPath: {
43
45
  // User Paths (ensure keys match UserPathData / DesktopAppState interface)
@@ -216,6 +218,14 @@ export default class SystemController extends ControllerModule {
216
218
  return result.filePaths[0];
217
219
  }
218
220
 
221
+ /**
222
+ * Get the OS system locale
223
+ */
224
+ @IpcMethod()
225
+ getSystemLocale(): string {
226
+ return app.getLocale();
227
+ }
228
+
219
229
  /**
220
230
  * 更新应用语言设置
221
231
  */
@@ -1,24 +1,15 @@
1
- import {
2
- DEFAULT_VARIANTS,
3
- LOBE_LOCALE_COOKIE,
4
- LOBE_THEME_APPEARANCE,
5
- Locales,
6
- RouteVariants,
7
- } from '@lobechat/desktop-bridge';
8
1
  import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
9
- import { app, nativeTheme, protocol, session } from 'electron';
2
+ import { app, nativeTheme, protocol } from 'electron';
10
3
  import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
11
4
  import { macOS, windows } from 'electron-is';
12
- import { pathExistsSync } from 'fs-extra';
13
5
  import os from 'node:os';
14
- import { extname, join } from 'node:path';
6
+ import { join } from 'node:path';
15
7
 
16
8
  import { name } from '@/../../package.json';
17
- import { buildDir, nextExportDir } from '@/const/dir';
9
+ import { buildDir } from '@/const/dir';
18
10
  import { isDev } from '@/const/env';
19
11
  import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
20
12
  import { IControlModule } from '@/controllers';
21
- import { getDesktopEnv } from '@/env';
22
13
  import { IServiceModule } from '@/services';
23
14
  import { getServerMethodMetadata } from '@/utils/ipc';
24
15
  import { createLogger } from '@/utils/logger';
@@ -27,7 +18,7 @@ import { BrowserManager } from './browser/BrowserManager';
27
18
  import { I18nManager } from './infrastructure/I18nManager';
28
19
  import { IoCContainer } from './infrastructure/IoCContainer';
29
20
  import { ProtocolManager } from './infrastructure/ProtocolManager';
30
- import { RendererProtocolManager } from './infrastructure/RendererProtocolManager';
21
+ import { RendererUrlManager } from './infrastructure/RendererUrlManager';
31
22
  import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
32
23
  import { StoreManager } from './infrastructure/StoreManager';
33
24
  import { UpdaterManager } from './infrastructure/UpdaterManager';
@@ -45,11 +36,7 @@ type Class<T> = new (...args: any[]) => T;
45
36
 
46
37
  const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
47
38
 
48
- const devDefaultRendererUrl = 'http://localhost:3015';
49
-
50
39
  export class App {
51
- rendererLoadedUrl: string;
52
-
53
40
  browserManager: BrowserManager;
54
41
  menuManager: MenuManager;
55
42
  i18n: I18nManager;
@@ -59,12 +46,8 @@ export class App {
59
46
  trayManager: TrayManager;
60
47
  staticFileServerManager: StaticFileServerManager;
61
48
  protocolManager: ProtocolManager;
62
- rendererProtocolManager: RendererProtocolManager;
49
+ rendererUrlManager: RendererUrlManager;
63
50
  chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
64
- /**
65
- * Escape hatch: allow testing static renderer in dev via env
66
- */
67
- private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
68
51
 
69
52
  /**
70
53
  * whether app is in quiting
@@ -96,10 +79,7 @@ export class App {
96
79
  // Initialize store manager
97
80
  this.storeManager = new StoreManager(this);
98
81
 
99
- this.rendererProtocolManager = new RendererProtocolManager({
100
- nextExportDir,
101
- resolveRendererFilePath: this.resolveRendererFilePath.bind(this),
102
- });
82
+ this.rendererUrlManager = new RendererUrlManager();
103
83
  protocol.registerSchemesAsPrivileged([
104
84
  {
105
85
  privileges: {
@@ -111,12 +91,9 @@ export class App {
111
91
  },
112
92
  scheme: ELECTRON_BE_PROTOCOL_SCHEME,
113
93
  },
114
- this.rendererProtocolManager.protocolScheme,
94
+ this.rendererUrlManager.protocolScheme,
115
95
  ]);
116
96
 
117
- // Initialize rendererLoadedUrl from RendererProtocolManager
118
- this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
119
-
120
97
  // load controllers
121
98
  const controllers: IControlModule[] = importAll(
122
99
  import.meta.glob('@/controllers/*Ctr.ts', { eager: true }),
@@ -146,7 +123,7 @@ export class App {
146
123
 
147
124
  // Configure renderer loading strategy (dev server vs static export)
148
125
  // should register before app ready
149
- this.configureRendererLoader();
126
+ this.rendererUrlManager.configureRendererLoader();
150
127
 
151
128
  // initialize protocol handlers
152
129
  this.protocolManager.initialize();
@@ -385,166 +362,11 @@ export class App {
385
362
  }
386
363
  };
387
364
 
388
- private resolveExportFilePath(pathname: string) {
389
- // Normalize by removing leading/trailing slashes so extname works as expected
390
- const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
391
-
392
- if (!normalizedPath) return join(nextExportDir, 'index.html');
393
-
394
- const basePath = join(nextExportDir, normalizedPath);
395
- const ext = extname(normalizedPath);
396
-
397
- // If the request explicitly includes an extension (e.g. html, ico, txt),
398
- // treat it as a direct asset without variant injection.
399
- if (ext) {
400
- return pathExistsSync(basePath) ? basePath : null;
401
- }
402
-
403
- const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
404
-
405
- for (const candidate of candidates) {
406
- if (pathExistsSync(candidate)) return candidate;
407
- }
408
-
409
- const fallback404 = join(nextExportDir, '404.html');
410
- if (pathExistsSync(fallback404)) return fallback404;
411
-
412
- return null;
413
- }
414
-
415
365
  /**
416
- * Configure renderer loading strategy for dev/prod
417
- */
418
- private configureRendererLoader() {
419
- if (isDev && !this.rendererStaticOverride) {
420
- this.rendererLoadedUrl = devDefaultRendererUrl;
421
- this.setupDevRenderer();
422
- return;
423
- }
424
-
425
- if (isDev && this.rendererStaticOverride) {
426
- logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
427
- }
428
-
429
- this.setupProdRenderer();
430
- }
431
-
432
- /**
433
- * Development: use Next dev server directly
434
- */
435
- private setupDevRenderer() {
436
- logger.info('Development mode: renderer served from Next dev server, no protocol hook');
437
- }
438
-
439
- /**
440
- * Production: serve static Next export assets
441
- */
442
- private setupProdRenderer() {
443
- // Use the URL from RendererProtocolManager
444
- this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
445
- this.rendererProtocolManager.registerHandler();
446
- }
447
-
448
- /**
449
- * Resolve renderer file path in production by combining variant prefix and pathname.
450
- * Falls back to default variant when cookies are missing or invalid.
451
- */
452
- private async resolveRendererFilePath(url: URL) {
453
- const pathname = url.pathname;
454
- const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
455
-
456
- // Static assets should be resolved from root (no variant prefix)
457
- if (
458
- pathname.startsWith('/_next/') ||
459
- pathname.startsWith('/static/') ||
460
- pathname === '/favicon.ico' ||
461
- pathname === '/manifest.json'
462
- ) {
463
- return this.resolveExportFilePath(pathname);
464
- }
465
-
466
- // If the incoming path already contains an extension (like .html or .ico),
467
- // treat it as a direct asset lookup to avoid double variant prefixes.
468
- const extension = extname(normalizedPathname);
469
- if (extension) {
470
- const directPath = this.resolveExportFilePath(pathname);
471
- if (directPath) return directPath;
472
-
473
- // Next.js RSC payloads are emitted under variant folders (e.g. /en-US__0__light/__next._tree.txt),
474
- // but the runtime may request them without the variant prefix. For missing .txt requests,
475
- // retry resolution with variant injection.
476
- if (extension === '.txt' && normalizedPathname.includes('__next.')) {
477
- const variant = await this.getRouteVariantFromCookies();
478
-
479
- return (
480
- this.resolveExportFilePath(`/${variant}${pathname}`) ||
481
- this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
482
- null
483
- );
484
- }
485
-
486
- return null;
487
- }
488
-
489
- const variant = await this.getRouteVariantFromCookies();
490
- const variantPrefixedPath = `/${variant}${pathname}`;
491
-
492
- // Try variant-specific path first, then default variant as fallback
493
- return (
494
- this.resolveExportFilePath(variantPrefixedPath) ||
495
- this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
496
- null
497
- );
498
- }
499
-
500
- private readonly defaultRouteVariant = RouteVariants.serializeVariants(DEFAULT_VARIANTS);
501
- private readonly localeCookieName = LOBE_LOCALE_COOKIE;
502
- private readonly themeCookieName = LOBE_THEME_APPEARANCE;
503
-
504
- /**
505
- * Build variant string from Electron session cookies to match Next export structure.
506
- * Desktop is always treated as non-mobile (0).
507
- */
508
- private async getRouteVariantFromCookies(): Promise<string> {
509
- try {
510
- const cookies = await session.defaultSession.cookies.get({
511
- url: `${this.rendererLoadedUrl}/`,
512
- });
513
- const locale = cookies.find((c) => c.name === this.localeCookieName)?.value;
514
- const themeCookie = cookies.find((c) => c.name === this.themeCookieName)?.value;
515
-
516
- const serialized = RouteVariants.serializeVariants(
517
- RouteVariants.createVariants({
518
- isMobile: false,
519
- locale: locale as Locales | undefined,
520
- theme: themeCookie === 'dark' || themeCookie === 'light' ? themeCookie : undefined,
521
- }),
522
- );
523
-
524
- return RouteVariants.serializeVariants(RouteVariants.deserializeVariants(serialized));
525
- } catch (error) {
526
- logger.warn('Failed to read route variant cookies, using default', error);
527
- return this.defaultRouteVariant;
528
- }
529
- }
530
-
531
- /**
532
- * Build renderer URL with variant prefix injected into the path.
533
- * In dev mode (without static override), Next.js dev server handles routing automatically.
534
- * In prod or dev with static override, we need to inject variant to match export structure: /[variants]/path
366
+ * Build renderer URL for dev/prod.
535
367
  */
536
368
  async buildRendererUrl(path: string): Promise<string> {
537
- // Ensure path starts with /
538
- const cleanPath = path.startsWith('/') ? path : `/${path}`;
539
-
540
- // In dev mode without static override, use dev server directly (no variant needed)
541
- if (isDev && !this.rendererStaticOverride) {
542
- return `${this.rendererLoadedUrl}${cleanPath}`;
543
- }
544
-
545
- // In prod or dev with static override, inject variant for static export structure
546
- const variant = await this.getRouteVariantFromCookies();
547
- return `${this.rendererLoadedUrl}/${variant}.html${cleanPath}`;
369
+ return this.rendererUrlManager.buildRendererUrl(path);
548
370
  }
549
371
 
550
372
  private initializeServerIpcEvents() {
@@ -12,6 +12,7 @@ vi.mock('electron', () => ({
12
12
  getLocale: vi.fn(() => 'en-US'),
13
13
  getPath: vi.fn(() => '/mock/user/path'),
14
14
  requestSingleInstanceLock: vi.fn(() => true),
15
+ isReady: vi.fn(() => true),
15
16
  whenReady: vi.fn(() => Promise.resolve()),
16
17
  on: vi.fn(),
17
18
  commandLine: {
@@ -32,6 +33,7 @@ vi.mock('electron', () => ({
32
33
  },
33
34
  protocol: {
34
35
  registerSchemesAsPrivileged: vi.fn(),
36
+ handle: vi.fn(),
35
37
  },
36
38
  session: {
37
39
  defaultSession: {
@@ -83,6 +85,10 @@ vi.mock('@/const/env', () => ({
83
85
  isDev: false,
84
86
  }));
85
87
 
88
+ vi.mock('@/env', () => ({
89
+ getDesktopEnv: vi.fn(() => ({ DESKTOP_RENDERER_STATIC: false })),
90
+ }));
91
+
86
92
  vi.mock('@/const/dir', () => ({
87
93
  buildDir: '/mock/build',
88
94
  nextExportDir: '/mock/export/out',
@@ -190,46 +196,4 @@ describe('App', () => {
190
196
  expect(storagePath).toBe('/mock/storage/path');
191
197
  });
192
198
  });
193
-
194
- describe('resolveRendererFilePath', () => {
195
- it('should retry missing .txt requests with variant-prefixed lookup', async () => {
196
- appInstance = new App();
197
-
198
- // Avoid touching the electron session cookie code path in this unit test
199
- (appInstance as any).getRouteVariantFromCookies = vi.fn(async () => 'en-US__0__light');
200
-
201
- mockPathExistsSync.mockImplementation((p: string) => {
202
- // root miss
203
- if (p === '/mock/export/out/__next._tree.txt') return false;
204
- // variant hit
205
- if (p === '/mock/export/out/en-US__0__light/__next._tree.txt') return true;
206
- return false;
207
- });
208
-
209
- const resolved = await (appInstance as any).resolveRendererFilePath(
210
- new URL('app://next/__next._tree.txt'),
211
- );
212
-
213
- expect(resolved).toBe('/mock/export/out/en-US__0__light/__next._tree.txt');
214
- });
215
-
216
- it('should keep direct lookup for existing root .txt assets (no variant retry)', async () => {
217
- appInstance = new App();
218
-
219
- (appInstance as any).getRouteVariantFromCookies = vi.fn(async () => {
220
- throw new Error('should not be called');
221
- });
222
-
223
- mockPathExistsSync.mockImplementation((p: string) => {
224
- if (p === '/mock/export/out/en-US__0__light.txt') return true;
225
- return false;
226
- });
227
-
228
- const resolved = await (appInstance as any).resolveRendererFilePath(
229
- new URL('app://next/en-US__0__light.txt'),
230
- );
231
-
232
- expect(resolved).toBe('/mock/export/out/en-US__0__light.txt');
233
- });
234
- });
235
199
  });
@@ -168,15 +168,23 @@ export default class Browser {
168
168
  loadUrl = async (path: string) => {
169
169
  const initUrl = await this.app.buildRendererUrl(path);
170
170
 
171
- console.log('[Browser] initUrl', initUrl);
171
+ // Inject locale from store to help renderer boot with the correct language.
172
+ // Skip when set to auto to let the renderer detect locale normally.
173
+ const storedLocale = this.app.storeManager.get('locale', 'auto');
174
+ const urlWithLocale =
175
+ storedLocale && storedLocale !== 'auto'
176
+ ? `${initUrl}${initUrl.includes('?') ? '&' : '?'}lng=${storedLocale}`
177
+ : initUrl;
178
+
179
+ console.log('[Browser] initUrl', urlWithLocale);
172
180
 
173
181
  try {
174
- logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`);
175
- await this._browserWindow.loadURL(initUrl);
182
+ logger.debug(`[${this.identifier}] Attempting to load URL: ${urlWithLocale}`);
183
+ await this._browserWindow.loadURL(urlWithLocale);
176
184
 
177
- logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`);
185
+ logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
178
186
  } catch (error) {
179
- logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error);
187
+ logger.error(`[${this.identifier}] Failed to load URL (${urlWithLocale}):`, error);
180
188
 
181
189
  // Try to load local error page
182
190
  try {
@@ -190,13 +198,13 @@ export default class Browser {
190
198
 
191
199
  // Set retry logic
192
200
  ipcMain.handle('retry-connection', async () => {
193
- logger.info(`[${this.identifier}] Retry connection requested for: ${initUrl}`);
201
+ logger.info(`[${this.identifier}] Retry connection requested for: ${urlWithLocale}`);
194
202
  try {
195
- await this._browserWindow?.loadURL(initUrl);
196
- logger.info(`[${this.identifier}] Reconnection successful to ${initUrl}`);
203
+ await this._browserWindow?.loadURL(urlWithLocale);
204
+ logger.info(`[${this.identifier}] Reconnection successful to ${urlWithLocale}`);
197
205
  return { success: true };
198
206
  } catch (err) {
199
- logger.error(`[${this.identifier}] Retry connection failed for ${initUrl}:`, err);
207
+ logger.error(`[${this.identifier}] Retry connection failed for ${urlWithLocale}:`, err);
200
208
  // Reload error page
201
209
  try {
202
210
  logger.info(`[${this.identifier}] Reloading error page after failed retry...`);
@@ -0,0 +1,126 @@
1
+ import { pathExistsSync } from 'fs-extra';
2
+ import { extname, join } from 'node:path';
3
+
4
+ import { nextExportDir } from '@/const/dir';
5
+ import { isDev } from '@/const/env';
6
+ import { getDesktopEnv } from '@/env';
7
+ import { createLogger } from '@/utils/logger';
8
+
9
+ import { RendererProtocolManager } from './RendererProtocolManager';
10
+
11
+ const logger = createLogger('core:RendererUrlManager');
12
+ const devDefaultRendererUrl = 'http://localhost:3015';
13
+
14
+ export class RendererUrlManager {
15
+ private readonly rendererProtocolManager: RendererProtocolManager;
16
+ private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
17
+ private rendererLoadedUrl: string;
18
+
19
+ constructor() {
20
+ this.rendererProtocolManager = new RendererProtocolManager({
21
+ nextExportDir,
22
+ resolveRendererFilePath: this.resolveRendererFilePath,
23
+ });
24
+
25
+ this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
26
+ }
27
+
28
+ get protocolScheme() {
29
+ return this.rendererProtocolManager.protocolScheme;
30
+ }
31
+
32
+ /**
33
+ * Configure renderer loading strategy for dev/prod
34
+ */
35
+ configureRendererLoader() {
36
+ if (isDev && !this.rendererStaticOverride) {
37
+ this.rendererLoadedUrl = devDefaultRendererUrl;
38
+ this.setupDevRenderer();
39
+ return;
40
+ }
41
+
42
+ if (isDev && this.rendererStaticOverride) {
43
+ logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
44
+ }
45
+
46
+ this.setupProdRenderer();
47
+ }
48
+
49
+ /**
50
+ * Build renderer URL for dev/prod.
51
+ */
52
+ buildRendererUrl(path: string): string {
53
+ const cleanPath = path.startsWith('/') ? path : `/${path}`;
54
+ return `${this.rendererLoadedUrl}${cleanPath}`;
55
+ }
56
+
57
+ /**
58
+ * Resolve renderer file path in production.
59
+ * Static assets map directly; app routes fall back to index.html.
60
+ */
61
+ resolveRendererFilePath = async (url: URL): Promise<string | null> => {
62
+ const pathname = url.pathname;
63
+ const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
64
+
65
+ // Static assets should be resolved from root
66
+ if (
67
+ pathname.startsWith('/_next/') ||
68
+ pathname.startsWith('/static/') ||
69
+ pathname === '/favicon.ico' ||
70
+ pathname === '/manifest.json'
71
+ ) {
72
+ return this.resolveExportFilePath(pathname);
73
+ }
74
+
75
+ // If the incoming path already contains an extension (like .html or .ico),
76
+ // treat it as a direct asset lookup.
77
+ const extension = extname(normalizedPathname);
78
+ if (extension) {
79
+ return this.resolveExportFilePath(pathname);
80
+ }
81
+
82
+ return this.resolveExportFilePath('/');
83
+ };
84
+
85
+ private resolveExportFilePath(pathname: string) {
86
+ // Normalize by removing leading/trailing slashes so extname works as expected
87
+ const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
88
+
89
+ if (!normalizedPath) return join(nextExportDir, 'index.html');
90
+
91
+ const basePath = join(nextExportDir, normalizedPath);
92
+ const ext = extname(normalizedPath);
93
+
94
+ // If the request explicitly includes an extension (e.g. html, ico, txt),
95
+ // treat it as a direct asset.
96
+ if (ext) {
97
+ return pathExistsSync(basePath) ? basePath : null;
98
+ }
99
+
100
+ const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
101
+
102
+ for (const candidate of candidates) {
103
+ if (pathExistsSync(candidate)) return candidate;
104
+ }
105
+
106
+ const fallback404 = join(nextExportDir, '404.html');
107
+ if (pathExistsSync(fallback404)) return fallback404;
108
+
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Development: use Next dev server directly
114
+ */
115
+ private setupDevRenderer() {
116
+ logger.info('Development mode: renderer served from Next dev server, no protocol hook');
117
+ }
118
+
119
+ /**
120
+ * Production: serve static Next export assets
121
+ */
122
+ private setupProdRenderer() {
123
+ this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
124
+ this.rendererProtocolManager.registerHandler();
125
+ }
126
+ }