@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.
- package/.vscode/settings.json +2 -17
- package/CHANGELOG.md +25 -0
- package/apps/desktop/src/main/controllers/SystemCtr.ts +10 -0
- package/apps/desktop/src/main/core/App.ts +10 -188
- package/apps/desktop/src/main/core/__tests__/App.test.ts +6 -42
- package/apps/desktop/src/main/core/browser/Browser.ts +17 -9
- package/apps/desktop/src/main/core/infrastructure/RendererUrlManager.ts +126 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/RendererUrlManager.test.ts +72 -0
- package/changelog/v1.json +5 -0
- package/package.json +1 -1
- package/packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx +1 -1
- package/packages/desktop-bridge/src/index.ts +0 -2
- package/packages/desktop-bridge/src/routeVariants.ts +0 -2
- package/packages/electron-client-ipc/src/types/system.ts +1 -0
- package/packages/model-bank/src/aiModels/lobehub.ts +0 -3
- package/packages/model-runtime/src/core/streams/openai/openai.test.ts +167 -0
- package/packages/model-runtime/src/core/streams/openai/openai.ts +30 -6
- package/packages/model-runtime/src/core/streams/protocol.ts +5 -0
- package/packages/model-runtime/src/core/streams/qwen.test.ts +131 -2
- package/packages/model-runtime/src/core/streams/qwen.ts +9 -1
- package/scripts/electronWorkflow/modifiers/index.mts +2 -0
- package/scripts/electronWorkflow/modifiers/nextConfig.mts +1 -1
- package/scripts/electronWorkflow/modifiers/staticExport.mts +174 -0
- package/src/layout/GlobalProvider/Locale.tsx +1 -1
- package/src/store/electron/actions/app.ts +6 -0
- package/src/utils/server/routeVariants.ts +2 -2
package/.vscode/settings.json
CHANGED
|
@@ -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
|
+
[](#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
|
|
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 {
|
|
6
|
+
import { join } from 'node:path';
|
|
15
7
|
|
|
16
8
|
import { name } from '@/../../package.json';
|
|
17
|
-
import { buildDir
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
175
|
-
await this._browserWindow.loadURL(
|
|
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: ${
|
|
185
|
+
logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
|
|
178
186
|
} catch (error) {
|
|
179
|
-
logger.error(`[${this.identifier}] Failed to load URL (${
|
|
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: ${
|
|
201
|
+
logger.info(`[${this.identifier}] Retry connection requested for: ${urlWithLocale}`);
|
|
194
202
|
try {
|
|
195
|
-
await this._browserWindow?.loadURL(
|
|
196
|
-
logger.info(`[${this.identifier}] Reconnection successful to ${
|
|
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 ${
|
|
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
|
+
}
|