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:
Innei
2026-01-05 00:59:35 +08:00
committed by GitHub
parent 5f6be91a88
commit 0205cf73bd
16 changed files with 430 additions and 264 deletions

19
.vscode/settings.json vendored
View File

@@ -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}"
}
}

View File

@@ -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();
}
/**
* 更新应用语言设置
*/

View File

@@ -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() {

View File

@@ -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');
});
});
});

View File

@@ -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...`);

View 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();
}
}

View File

@@ -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');
});
});
});

View File

@@ -3,8 +3,6 @@ export {
DEFAULT_LANG,
DEFAULT_VARIANTS,
type IRouteVariants,
LOBE_LOCALE_COOKIE,
LOBE_THEME_APPEARANCE,
type Locales,
locales,
RouteVariants,

View File

@@ -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)

View File

@@ -3,6 +3,7 @@ export interface ElectronAppState {
isLinux?: boolean;
isMac?: boolean;
isWindows?: boolean;
locale?: string;
platform?: 'darwin' | 'win32' | 'linux';
systemAppearance?: string;
userPath?: UserPathData;

View File

@@ -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);
};

View File

@@ -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),
});
};

View 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' },
]);
}

View File

@@ -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);

View File

@@ -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);
},
},
),

View File

@@ -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';