mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
refactor: Extract renderer URL and protocol management into dedicated manager (#11208)
* feat: Add static export modifier for Electron, refactor route variant constants, and simplify renderer file path resolution. * refactor: Extract renderer URL and protocol management into a dedicated `RendererUrlManager` and update `App` to utilize it. Signed-off-by: Innei <tukon479@gmail.com> * feat: Implement Electron app locale management and i18n initialization based on stored settings. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
19
.vscode/settings.json
vendored
19
.vscode/settings.json
vendored
@@ -26,9 +26,9 @@
|
||||
],
|
||||
"npm.packageManager": "pnpm",
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/node_modules": true
|
||||
// useless to search this big folder
|
||||
"locales": true
|
||||
// "locales": true
|
||||
},
|
||||
"stylelint.validate": [
|
||||
"css",
|
||||
@@ -41,58 +41,43 @@
|
||||
"**/app/**/[[]*[]]/[[]*[]]/page.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • page component",
|
||||
"**/app/**/[[]*[]]/page.tsx": "${dirname(1)}/${dirname} • page component",
|
||||
"**/app/**/page.tsx": "${dirname} • page component",
|
||||
|
||||
"**/app/**/[[]*[]]/[[]*[]]/layout.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • page layout",
|
||||
"**/app/**/[[]*[]]/layout.tsx": "${dirname(1)}/${dirname} • page layout",
|
||||
"**/app/**/layout.tsx": "${dirname} • page layout",
|
||||
|
||||
"**/app/**/[[]*[]]/[[]*[]]/default.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • slot default",
|
||||
"**/app/**/[[]*[]]/default.tsx": "${dirname(1)}/${dirname} • slot default",
|
||||
"**/app/**/default.tsx": "${dirname} • slot default",
|
||||
|
||||
"**/app/**/[[]*[]]/[[]*[]]/error.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • error component",
|
||||
"**/app/**/[[]*[]]/error.tsx": "${dirname(1)}/${dirname} • error component",
|
||||
"**/app/**/error.tsx": "${dirname} • error component",
|
||||
|
||||
"**/app/**/[[]*[]]/[[]*[]]/loading.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • loading component",
|
||||
"**/app/**/[[]*[]]/loading.tsx": "${dirname(1)}/${dirname} • loading component",
|
||||
"**/app/**/loading.tsx": "${dirname} • loading component",
|
||||
|
||||
"**/src/**/route.ts": "${dirname(1)}/${dirname} • route",
|
||||
"**/src/**/index.tsx": "${dirname} • component",
|
||||
|
||||
"**/packages/database/src/repositories/*/index.ts": "${dirname} • db repository",
|
||||
"**/packages/database/src/models/*.ts": "${filename} • db model",
|
||||
"**/packages/database/src/schemas/*.ts": "${filename} • db schema",
|
||||
|
||||
"**/src/services/*.ts": "${filename} • service",
|
||||
"**/src/services/*/client.ts": "${dirname} • client service",
|
||||
"**/src/services/*/server.ts": "${dirname} • server service",
|
||||
|
||||
"**/src/store/*/action.ts": "${dirname} • action",
|
||||
"**/src/store/*/slices/*/action.ts": "${dirname(2)}/${dirname} • action",
|
||||
"**/src/store/*/slices/*/actions/*.ts": "${dirname(1)}/${dirname}/${filename} • action",
|
||||
|
||||
"**/src/store/*/initialState.ts": "${dirname} • state",
|
||||
"**/src/store/*/slices/*/initialState.ts": "${dirname(2)}/${dirname} • state",
|
||||
|
||||
"**/src/store/*/selectors.ts": "${dirname} • selectors",
|
||||
"**/src/store/*/slices/*/selectors.ts": "${dirname(2)}/${dirname} • selectors",
|
||||
|
||||
"**/src/store/*/reducer.ts": "${dirname} • reducer",
|
||||
"**/src/store/*/slices/*/reducer.ts": "${dirname(2)}/${dirname} • reducer",
|
||||
|
||||
"**/src/config/modelProviders/*.ts": "${filename} • provider",
|
||||
"**/packages/model-bank/src/aiModels/*.ts": "${filename} • model",
|
||||
"**/packages/model-runtime/src/providers/*/index.ts": "${dirname} • runtime",
|
||||
|
||||
"**/src/server/services/*/index.ts": "${dirname} • server/service",
|
||||
"**/src/server/routers/lambda/*.ts": "${filename} • lambda",
|
||||
"**/src/server/routers/async/*.ts": "${filename} • async",
|
||||
"**/src/server/routers/edge/*.ts": "${filename} • edge",
|
||||
|
||||
"**/src/locales/default/*.ts": "${filename} • locale",
|
||||
|
||||
"**/index.*": "${dirname}/${filename}.${extname}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export default class SystemController extends ControllerModule {
|
||||
isLinux: platform === 'linux',
|
||||
isMac: platform === 'darwin',
|
||||
isWindows: platform === 'win32',
|
||||
locale: this.app.storeManager.get('locale', 'auto'),
|
||||
|
||||
platform: platform as 'darwin' | 'win32' | 'linux',
|
||||
userPath: {
|
||||
// User Paths (ensure keys match UserPathData / DesktopAppState interface)
|
||||
@@ -216,6 +218,14 @@ export default class SystemController extends ControllerModule {
|
||||
return result.filePaths[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OS system locale
|
||||
*/
|
||||
@IpcMethod()
|
||||
getSystemLocale(): string {
|
||||
return app.getLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新应用语言设置
|
||||
*/
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import {
|
||||
DEFAULT_VARIANTS,
|
||||
LOBE_LOCALE_COOKIE,
|
||||
LOBE_THEME_APPEARANCE,
|
||||
Locales,
|
||||
RouteVariants,
|
||||
} from '@lobechat/desktop-bridge';
|
||||
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { app, nativeTheme, protocol, session } from 'electron';
|
||||
import { app, nativeTheme, protocol } from 'electron';
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import { extname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { buildDir, nextExportDir } from '@/const/dir';
|
||||
import { buildDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { getServerMethodMetadata } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -27,7 +18,7 @@ import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererProtocolManager } from './infrastructure/RendererProtocolManager';
|
||||
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
import { StoreManager } from './infrastructure/StoreManager';
|
||||
import { UpdaterManager } from './infrastructure/UpdaterManager';
|
||||
@@ -45,11 +36,7 @@ type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
|
||||
|
||||
const devDefaultRendererUrl = 'http://localhost:3015';
|
||||
|
||||
export class App {
|
||||
rendererLoadedUrl: string;
|
||||
|
||||
browserManager: BrowserManager;
|
||||
menuManager: MenuManager;
|
||||
i18n: I18nManager;
|
||||
@@ -59,12 +46,8 @@ export class App {
|
||||
trayManager: TrayManager;
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererProtocolManager: RendererProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
/**
|
||||
* Escape hatch: allow testing static renderer in dev via env
|
||||
*/
|
||||
private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
|
||||
|
||||
/**
|
||||
* whether app is in quiting
|
||||
@@ -96,10 +79,7 @@ export class App {
|
||||
// Initialize store manager
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererProtocolManager = new RendererProtocolManager({
|
||||
nextExportDir,
|
||||
resolveRendererFilePath: this.resolveRendererFilePath.bind(this),
|
||||
});
|
||||
this.rendererUrlManager = new RendererUrlManager();
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
@@ -111,12 +91,9 @@ export class App {
|
||||
},
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererProtocolManager.protocolScheme,
|
||||
this.rendererUrlManager.protocolScheme,
|
||||
]);
|
||||
|
||||
// Initialize rendererLoadedUrl from RendererProtocolManager
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
|
||||
// load controllers
|
||||
const controllers: IControlModule[] = importAll(
|
||||
import.meta.glob('@/controllers/*Ctr.ts', { eager: true }),
|
||||
@@ -146,7 +123,7 @@ export class App {
|
||||
|
||||
// Configure renderer loading strategy (dev server vs static export)
|
||||
// should register before app ready
|
||||
this.configureRendererLoader();
|
||||
this.rendererUrlManager.configureRendererLoader();
|
||||
|
||||
// initialize protocol handlers
|
||||
this.protocolManager.initialize();
|
||||
@@ -385,166 +362,11 @@ export class App {
|
||||
}
|
||||
};
|
||||
|
||||
private resolveExportFilePath(pathname: string) {
|
||||
// Normalize by removing leading/trailing slashes so extname works as expected
|
||||
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
|
||||
|
||||
if (!normalizedPath) return join(nextExportDir, 'index.html');
|
||||
|
||||
const basePath = join(nextExportDir, normalizedPath);
|
||||
const ext = extname(normalizedPath);
|
||||
|
||||
// If the request explicitly includes an extension (e.g. html, ico, txt),
|
||||
// treat it as a direct asset without variant injection.
|
||||
if (ext) {
|
||||
return pathExistsSync(basePath) ? basePath : null;
|
||||
}
|
||||
|
||||
const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (pathExistsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const fallback404 = join(nextExportDir, '404.html');
|
||||
if (pathExistsSync(fallback404)) return fallback404;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure renderer loading strategy for dev/prod
|
||||
*/
|
||||
private configureRendererLoader() {
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
this.rendererLoadedUrl = devDefaultRendererUrl;
|
||||
this.setupDevRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDev && this.rendererStaticOverride) {
|
||||
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
|
||||
}
|
||||
|
||||
this.setupProdRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Development: use Next dev server directly
|
||||
*/
|
||||
private setupDevRenderer() {
|
||||
logger.info('Development mode: renderer served from Next dev server, no protocol hook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Production: serve static Next export assets
|
||||
*/
|
||||
private setupProdRenderer() {
|
||||
// Use the URL from RendererProtocolManager
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
this.rendererProtocolManager.registerHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve renderer file path in production by combining variant prefix and pathname.
|
||||
* Falls back to default variant when cookies are missing or invalid.
|
||||
*/
|
||||
private async resolveRendererFilePath(url: URL) {
|
||||
const pathname = url.pathname;
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
// Static assets should be resolved from root (no variant prefix)
|
||||
if (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/manifest.json'
|
||||
) {
|
||||
return this.resolveExportFilePath(pathname);
|
||||
}
|
||||
|
||||
// If the incoming path already contains an extension (like .html or .ico),
|
||||
// treat it as a direct asset lookup to avoid double variant prefixes.
|
||||
const extension = extname(normalizedPathname);
|
||||
if (extension) {
|
||||
const directPath = this.resolveExportFilePath(pathname);
|
||||
if (directPath) return directPath;
|
||||
|
||||
// Next.js RSC payloads are emitted under variant folders (e.g. /en-US__0__light/__next._tree.txt),
|
||||
// but the runtime may request them without the variant prefix. For missing .txt requests,
|
||||
// retry resolution with variant injection.
|
||||
if (extension === '.txt' && normalizedPathname.includes('__next.')) {
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
|
||||
return (
|
||||
this.resolveExportFilePath(`/${variant}${pathname}`) ||
|
||||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
const variantPrefixedPath = `/${variant}${pathname}`;
|
||||
|
||||
// Try variant-specific path first, then default variant as fallback
|
||||
return (
|
||||
this.resolveExportFilePath(variantPrefixedPath) ||
|
||||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private readonly defaultRouteVariant = RouteVariants.serializeVariants(DEFAULT_VARIANTS);
|
||||
private readonly localeCookieName = LOBE_LOCALE_COOKIE;
|
||||
private readonly themeCookieName = LOBE_THEME_APPEARANCE;
|
||||
|
||||
/**
|
||||
* Build variant string from Electron session cookies to match Next export structure.
|
||||
* Desktop is always treated as non-mobile (0).
|
||||
*/
|
||||
private async getRouteVariantFromCookies(): Promise<string> {
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({
|
||||
url: `${this.rendererLoadedUrl}/`,
|
||||
});
|
||||
const locale = cookies.find((c) => c.name === this.localeCookieName)?.value;
|
||||
const themeCookie = cookies.find((c) => c.name === this.themeCookieName)?.value;
|
||||
|
||||
const serialized = RouteVariants.serializeVariants(
|
||||
RouteVariants.createVariants({
|
||||
isMobile: false,
|
||||
locale: locale as Locales | undefined,
|
||||
theme: themeCookie === 'dark' || themeCookie === 'light' ? themeCookie : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return RouteVariants.serializeVariants(RouteVariants.deserializeVariants(serialized));
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read route variant cookies, using default', error);
|
||||
return this.defaultRouteVariant;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build renderer URL with variant prefix injected into the path.
|
||||
* In dev mode (without static override), Next.js dev server handles routing automatically.
|
||||
* In prod or dev with static override, we need to inject variant to match export structure: /[variants]/path
|
||||
* Build renderer URL for dev/prod.
|
||||
*/
|
||||
async buildRendererUrl(path: string): Promise<string> {
|
||||
// Ensure path starts with /
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
// In dev mode without static override, use dev server directly (no variant needed)
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
return `${this.rendererLoadedUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
// In prod or dev with static override, inject variant for static export structure
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
return `${this.rendererLoadedUrl}/${variant}.html${cleanPath}`;
|
||||
return this.rendererUrlManager.buildRendererUrl(path);
|
||||
}
|
||||
|
||||
private initializeServerIpcEvents() {
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('electron', () => ({
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn(() => '/mock/user/path'),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
isReady: vi.fn(() => true),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
on: vi.fn(),
|
||||
commandLine: {
|
||||
@@ -32,6 +33,7 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
handle: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
defaultSession: {
|
||||
@@ -83,6 +85,10 @@ vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/env', () => ({
|
||||
getDesktopEnv: vi.fn(() => ({ DESKTOP_RENDERER_STATIC: false })),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
nextExportDir: '/mock/export/out',
|
||||
@@ -190,46 +196,4 @@ describe('App', () => {
|
||||
expect(storagePath).toBe('/mock/storage/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRendererFilePath', () => {
|
||||
it('should retry missing .txt requests with variant-prefixed lookup', async () => {
|
||||
appInstance = new App();
|
||||
|
||||
// Avoid touching the electron session cookie code path in this unit test
|
||||
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => 'en-US__0__light');
|
||||
|
||||
mockPathExistsSync.mockImplementation((p: string) => {
|
||||
// root miss
|
||||
if (p === '/mock/export/out/__next._tree.txt') return false;
|
||||
// variant hit
|
||||
if (p === '/mock/export/out/en-US__0__light/__next._tree.txt') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const resolved = await (appInstance as any).resolveRendererFilePath(
|
||||
new URL('app://next/__next._tree.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light/__next._tree.txt');
|
||||
});
|
||||
|
||||
it('should keep direct lookup for existing root .txt assets (no variant retry)', async () => {
|
||||
appInstance = new App();
|
||||
|
||||
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => {
|
||||
throw new Error('should not be called');
|
||||
});
|
||||
|
||||
mockPathExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/mock/export/out/en-US__0__light.txt') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const resolved = await (appInstance as any).resolveRendererFilePath(
|
||||
new URL('app://next/en-US__0__light.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,15 +168,23 @@ export default class Browser {
|
||||
loadUrl = async (path: string) => {
|
||||
const initUrl = await this.app.buildRendererUrl(path);
|
||||
|
||||
console.log('[Browser] initUrl', initUrl);
|
||||
// Inject locale from store to help renderer boot with the correct language.
|
||||
// Skip when set to auto to let the renderer detect locale normally.
|
||||
const storedLocale = this.app.storeManager.get('locale', 'auto');
|
||||
const urlWithLocale =
|
||||
storedLocale && storedLocale !== 'auto'
|
||||
? `${initUrl}${initUrl.includes('?') ? '&' : '?'}lng=${storedLocale}`
|
||||
: initUrl;
|
||||
|
||||
console.log('[Browser] initUrl', urlWithLocale);
|
||||
|
||||
try {
|
||||
logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`);
|
||||
await this._browserWindow.loadURL(initUrl);
|
||||
logger.debug(`[${this.identifier}] Attempting to load URL: ${urlWithLocale}`);
|
||||
await this._browserWindow.loadURL(urlWithLocale);
|
||||
|
||||
logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`);
|
||||
logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error);
|
||||
logger.error(`[${this.identifier}] Failed to load URL (${urlWithLocale}):`, error);
|
||||
|
||||
// Try to load local error page
|
||||
try {
|
||||
@@ -190,13 +198,13 @@ export default class Browser {
|
||||
|
||||
// Set retry logic
|
||||
ipcMain.handle('retry-connection', async () => {
|
||||
logger.info(`[${this.identifier}] Retry connection requested for: ${initUrl}`);
|
||||
logger.info(`[${this.identifier}] Retry connection requested for: ${urlWithLocale}`);
|
||||
try {
|
||||
await this._browserWindow?.loadURL(initUrl);
|
||||
logger.info(`[${this.identifier}] Reconnection successful to ${initUrl}`);
|
||||
await this._browserWindow?.loadURL(urlWithLocale);
|
||||
logger.info(`[${this.identifier}] Reconnection successful to ${urlWithLocale}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.error(`[${this.identifier}] Retry connection failed for ${initUrl}:`, err);
|
||||
logger.error(`[${this.identifier}] Retry connection failed for ${urlWithLocale}:`, err);
|
||||
// Reload error page
|
||||
try {
|
||||
logger.info(`[${this.identifier}] Reloading error page after failed retry...`);
|
||||
|
||||
126
apps/desktop/src/main/core/infrastructure/RendererUrlManager.ts
Normal file
126
apps/desktop/src/main/core/infrastructure/RendererUrlManager.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
import { nextExportDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { RendererProtocolManager } from './RendererProtocolManager';
|
||||
|
||||
const logger = createLogger('core:RendererUrlManager');
|
||||
const devDefaultRendererUrl = 'http://localhost:3015';
|
||||
|
||||
export class RendererUrlManager {
|
||||
private readonly rendererProtocolManager: RendererProtocolManager;
|
||||
private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
|
||||
private rendererLoadedUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.rendererProtocolManager = new RendererProtocolManager({
|
||||
nextExportDir,
|
||||
resolveRendererFilePath: this.resolveRendererFilePath,
|
||||
});
|
||||
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
}
|
||||
|
||||
get protocolScheme() {
|
||||
return this.rendererProtocolManager.protocolScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure renderer loading strategy for dev/prod
|
||||
*/
|
||||
configureRendererLoader() {
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
this.rendererLoadedUrl = devDefaultRendererUrl;
|
||||
this.setupDevRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDev && this.rendererStaticOverride) {
|
||||
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
|
||||
}
|
||||
|
||||
this.setupProdRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build renderer URL for dev/prod.
|
||||
*/
|
||||
buildRendererUrl(path: string): string {
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${this.rendererLoadedUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve renderer file path in production.
|
||||
* Static assets map directly; app routes fall back to index.html.
|
||||
*/
|
||||
resolveRendererFilePath = async (url: URL): Promise<string | null> => {
|
||||
const pathname = url.pathname;
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
// Static assets should be resolved from root
|
||||
if (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/manifest.json'
|
||||
) {
|
||||
return this.resolveExportFilePath(pathname);
|
||||
}
|
||||
|
||||
// If the incoming path already contains an extension (like .html or .ico),
|
||||
// treat it as a direct asset lookup.
|
||||
const extension = extname(normalizedPathname);
|
||||
if (extension) {
|
||||
return this.resolveExportFilePath(pathname);
|
||||
}
|
||||
|
||||
return this.resolveExportFilePath('/');
|
||||
};
|
||||
|
||||
private resolveExportFilePath(pathname: string) {
|
||||
// Normalize by removing leading/trailing slashes so extname works as expected
|
||||
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
|
||||
|
||||
if (!normalizedPath) return join(nextExportDir, 'index.html');
|
||||
|
||||
const basePath = join(nextExportDir, normalizedPath);
|
||||
const ext = extname(normalizedPath);
|
||||
|
||||
// If the request explicitly includes an extension (e.g. html, ico, txt),
|
||||
// treat it as a direct asset.
|
||||
if (ext) {
|
||||
return pathExistsSync(basePath) ? basePath : null;
|
||||
}
|
||||
|
||||
const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (pathExistsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const fallback404 = join(nextExportDir, '404.html');
|
||||
if (pathExistsSync(fallback404)) return fallback404;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Development: use Next dev server directly
|
||||
*/
|
||||
private setupDevRenderer() {
|
||||
logger.info('Development mode: renderer served from Next dev server, no protocol hook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Production: serve static Next export assets
|
||||
*/
|
||||
private setupProdRenderer() {
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
this.rendererProtocolManager.registerHandler();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RendererUrlManager } from '../RendererUrlManager';
|
||||
|
||||
const mockPathExistsSync = vi.fn();
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isReady: vi.fn(() => true),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
protocol: {
|
||||
handle: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
pathExistsSync: (...args: any[]) => mockPathExistsSync(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
nextExportDir: '/mock/export/out',
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/env', () => ({
|
||||
getDesktopEnv: vi.fn(() => ({ DESKTOP_RENDERER_STATIC: false })),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('RendererUrlManager', () => {
|
||||
let manager: RendererUrlManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPathExistsSync.mockReset();
|
||||
manager = new RendererUrlManager();
|
||||
});
|
||||
|
||||
describe('resolveRendererFilePath', () => {
|
||||
it('should resolve asset requests directly', async () => {
|
||||
mockPathExistsSync.mockImplementation(
|
||||
(p: string) => p === '/mock/export/out/en-US__0__light.txt',
|
||||
);
|
||||
|
||||
const resolved = await manager.resolveRendererFilePath(
|
||||
new URL('app://next/en-US__0__light.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light.txt');
|
||||
});
|
||||
|
||||
it('should fall back to index.html for app routes', async () => {
|
||||
mockPathExistsSync.mockImplementation((p: string) => p === '/mock/export/out/index.html');
|
||||
|
||||
const resolved = await manager.resolveRendererFilePath(new URL('app://next/settings'));
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/index.html');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,6 @@ export {
|
||||
DEFAULT_LANG,
|
||||
DEFAULT_VARIANTS,
|
||||
type IRouteVariants,
|
||||
LOBE_LOCALE_COOKIE,
|
||||
LOBE_THEME_APPEARANCE,
|
||||
type Locales,
|
||||
locales,
|
||||
RouteVariants,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Shared route variants utilities for desktop and web builds
|
||||
|
||||
export const LOBE_LOCALE_COOKIE = 'LOBE_LOCALE';
|
||||
export const LOBE_THEME_APPEARANCE = 'LOBE_THEME_APPEARANCE';
|
||||
export const DEFAULT_LANG = 'en-US';
|
||||
|
||||
// Supported locales (keep aligned with web resources)
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ElectronAppState {
|
||||
isLinux?: boolean;
|
||||
isMac?: boolean;
|
||||
isWindows?: boolean;
|
||||
locale?: string;
|
||||
platform?: 'darwin' | 'win32' | 'linux';
|
||||
systemAppearance?: string;
|
||||
userPath?: UserPathData;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { modifyAppCode } from './appCode.mjs';
|
||||
import { cleanUpCode } from './cleanUp.mjs';
|
||||
import { modifyNextConfig } from './nextConfig.mjs';
|
||||
import { modifyRoutes } from './routes.mjs';
|
||||
import { modifyStaticExport } from './staticExport.mjs';
|
||||
import { isDirectRun, runStandalone } from './utils.mjs';
|
||||
|
||||
export const modifySourceForElectron = async (TEMP_DIR: string) => {
|
||||
await modifyNextConfig(TEMP_DIR);
|
||||
await modifyAppCode(TEMP_DIR);
|
||||
await modifyRoutes(TEMP_DIR);
|
||||
await modifyStaticExport(TEMP_DIR);
|
||||
await cleanUpCode(TEMP_DIR);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const modifyNextConfig = async (TEMP_DIR: string) => {
|
||||
|
||||
console.log(` Processing ${path.relative(TEMP_DIR, nextConfigPath)}...`);
|
||||
await updateFile({
|
||||
assertAfter: (code) => /output\s*:\s*["']export["']/.test(code) && !/withPWA\s*\(/.test(code),
|
||||
filePath: nextConfigPath,
|
||||
name: 'modifyNextConfig',
|
||||
transformer: (code) => {
|
||||
@@ -147,7 +148,6 @@ export const modifyNextConfig = async (TEMP_DIR: string) => {
|
||||
|
||||
return newCode;
|
||||
},
|
||||
assertAfter: (code) => /output\s*:\s*['"]export['"]/.test(code) && !/withPWA\s*\(/.test(code),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
174
scripts/electronWorkflow/modifiers/staticExport.mts
Normal file
174
scripts/electronWorkflow/modifiers/staticExport.mts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isDirectRun, runStandalone, updateFile } from './utils.mjs';
|
||||
|
||||
/**
|
||||
* Remove the URL rewrite logic from the proxy middleware.
|
||||
* For Electron static export, we don't need URL rewriting since pages are pre-rendered.
|
||||
*/
|
||||
const removeUrlRewriteLogic = (code: string): string => {
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
const edits: Array<{ end: number; start: number; text: string }> = [];
|
||||
|
||||
// Find the defaultMiddleware arrow function
|
||||
const defaultMiddleware = root.find({
|
||||
rule: {
|
||||
pattern: 'const defaultMiddleware = ($REQ) => { $$$ }',
|
||||
},
|
||||
});
|
||||
|
||||
if (!defaultMiddleware) {
|
||||
console.warn(' ⚠️ defaultMiddleware not found, skipping URL rewrite removal');
|
||||
return code;
|
||||
}
|
||||
|
||||
// Replace the entire defaultMiddleware function with a simplified version
|
||||
// that just returns NextResponse.next() for non-API routes
|
||||
const range = defaultMiddleware.range();
|
||||
|
||||
const simplifiedMiddleware = `const defaultMiddleware = (request: NextRequest) => {
|
||||
const url = new URL(request.url);
|
||||
logDefault('Processing request: %s %s', request.method, request.url);
|
||||
|
||||
// skip all api requests
|
||||
if (backendApiEndpoints.some((path) => url.pathname.startsWith(path))) {
|
||||
logDefault('Skipping API request: %s', url.pathname);
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}`;
|
||||
|
||||
edits.push({ end: range.end.index, start: range.start.index, text: simplifiedMiddleware });
|
||||
|
||||
// Apply edits
|
||||
if (edits.length === 0) return code;
|
||||
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
let result = code;
|
||||
for (const edit of edits) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const assertUrlRewriteRemoved = (code: string): boolean =>
|
||||
// Ensure the URL rewrite related code is removed
|
||||
!/NextResponse\.rewrite\(/.test(code) &&
|
||||
!/RouteVariants\.serializeVariants/.test(code) &&
|
||||
!/url\.pathname = nextPathname/.test(code);
|
||||
|
||||
/**
|
||||
* Rename [variants] directories to (variants) under src/app
|
||||
*/
|
||||
const renameVariantsDirectories = async (TEMP_DIR: string): Promise<void> => {
|
||||
const srcAppPath = path.join(TEMP_DIR, 'src', 'app');
|
||||
|
||||
// Recursively find and rename [variants] directories
|
||||
const renameRecursively = async (dir: string): Promise<void> => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const oldPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.name === '[variants]') {
|
||||
const newPath = path.join(dir, '(variants)');
|
||||
|
||||
// If (variants) already exists, remove it first
|
||||
if (await fs.pathExists(newPath)) {
|
||||
console.log(` Removing existing: ${path.relative(TEMP_DIR, newPath)}`);
|
||||
await fs.remove(newPath);
|
||||
}
|
||||
|
||||
console.log(
|
||||
` Renaming: ${path.relative(TEMP_DIR, oldPath)} -> ${path.relative(TEMP_DIR, newPath)}`,
|
||||
);
|
||||
await fs.rename(oldPath, newPath);
|
||||
// Continue searching in the renamed directory
|
||||
await renameRecursively(newPath);
|
||||
} else {
|
||||
// Continue searching in subdirectories
|
||||
await renameRecursively(oldPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await renameRecursively(srcAppPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update all imports that reference [variants] to use (variants)
|
||||
*/
|
||||
const updateVariantsImports = async (TEMP_DIR: string): Promise<void> => {
|
||||
const srcPath = path.join(TEMP_DIR, 'src');
|
||||
|
||||
// Pattern to match imports containing [variants]
|
||||
const variantsImportPattern = /(\[variants])/g;
|
||||
|
||||
const processFile = async (filePath: string): Promise<void> => {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
if (!content.includes('[variants]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = content.replaceAll('[variants]', '(variants)');
|
||||
|
||||
if (updated !== content) {
|
||||
console.log(` Updated imports: ${path.relative(TEMP_DIR, filePath)}`);
|
||||
await fs.writeFile(filePath, updated);
|
||||
}
|
||||
};
|
||||
|
||||
const processDirectory = async (dir: string): Promise<void> => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules and other non-source directories
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') {
|
||||
continue;
|
||||
}
|
||||
await processDirectory(fullPath);
|
||||
} else if (entry.isFile() && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name)) {
|
||||
await processFile(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processDirectory(srcPath);
|
||||
};
|
||||
|
||||
export const modifyStaticExport = async (TEMP_DIR: string): Promise<void> => {
|
||||
// 1. Remove URL rewrite logic from define-config.ts
|
||||
const defineConfigPath = path.join(TEMP_DIR, 'src', 'libs', 'next', 'proxy', 'define-config.ts');
|
||||
console.log(' Processing src/libs/next/proxy/define-config.ts...');
|
||||
await updateFile({
|
||||
assertAfter: assertUrlRewriteRemoved,
|
||||
filePath: defineConfigPath,
|
||||
name: 'modifyStaticExport:removeUrlRewrite',
|
||||
transformer: removeUrlRewriteLogic,
|
||||
});
|
||||
|
||||
// 2. Rename [variants] directories to (variants)
|
||||
console.log(' Renaming [variants] directories to (variants)...');
|
||||
await renameVariantsDirectories(TEMP_DIR);
|
||||
|
||||
// 3. Update all imports referencing [variants]
|
||||
console.log(' Updating imports referencing [variants]...');
|
||||
await updateVariantsImports(TEMP_DIR);
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('modifyStaticExport', modifyStaticExport, [
|
||||
{ lang: Lang.TypeScript, path: 'src/libs/next/proxy/define-config.ts' },
|
||||
]);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ interface LocaleLayoutProps extends PropsWithChildren {
|
||||
}
|
||||
|
||||
const Locale = memo<LocaleLayoutProps>(({ children, defaultLang, antdLocale }) => {
|
||||
const [i18n] = useState(createI18nNext(defaultLang));
|
||||
const [i18n] = useState(() => createI18nNext(defaultLang));
|
||||
const [lang, setLang] = useState(defaultLang);
|
||||
const [locale, setLocale] = useState(antdLocale);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { globalAgentContextManager } from '@/helpers/GlobalAgentContextManager';
|
||||
import { useOnlyFetchOnceSWR } from '@/libs/swr';
|
||||
// Import for type usage
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { type LocaleMode } from '@/types/locale';
|
||||
import { switchLang } from '@/utils/client/switchLang';
|
||||
import { merge } from '@/utils/merge';
|
||||
|
||||
import type { ElectronStore } from '../store';
|
||||
@@ -55,6 +57,10 @@ export const createElectronAppSlice: StateCreator<
|
||||
userDataPath: result.userPath!.userData,
|
||||
videosPath: result.userPath!.videos,
|
||||
});
|
||||
|
||||
// Initialize i18n with the stored locale, falling back to auto detection.
|
||||
const locale = (result.locale ?? 'auto') as LocaleMode;
|
||||
switchLang(locale);
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,12 +2,12 @@ import { RouteVariants } from '@lobechat/desktop-bridge';
|
||||
|
||||
import { type DynamicLayoutProps } from '@/types/next';
|
||||
|
||||
export { LOBE_LOCALE_COOKIE } from '@/const/locale';
|
||||
export { LOBE_THEME_APPEARANCE } from '@/const/theme';
|
||||
export {
|
||||
DEFAULT_LANG,
|
||||
DEFAULT_VARIANTS,
|
||||
type IRouteVariants,
|
||||
LOBE_LOCALE_COOKIE,
|
||||
LOBE_THEME_APPEARANCE,
|
||||
type Locales,
|
||||
locales,
|
||||
} from '@lobechat/desktop-bridge';
|
||||
|
||||
Reference in New Issue
Block a user