mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat(desktop): restore cloud desktop builds (#15666)
This commit is contained in:
@@ -6,6 +6,7 @@ import dotenv from 'dotenv';
|
||||
import { defineConfig } from 'electron-vite';
|
||||
import type { PluginOption, ViteDevServer } from 'vite';
|
||||
import { loadEnv } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
import {
|
||||
sharedOptimizeDeps,
|
||||
@@ -88,10 +89,112 @@ function electronDesktopHtmlPlugin(): PluginOption {
|
||||
};
|
||||
}
|
||||
|
||||
const CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG = '__LOBECLOUD_DESKTOP_BUSINESS_FEATURES__';
|
||||
const BUSINESS_CONST_MODULE_ID = '@lobechat/business-const';
|
||||
const CLOUD_BUSINESS_CONST_MODULE_ID = '@cloud/business-const';
|
||||
const DYNAMIC_BUSINESS_CONST_QUERY = '?lobe-cloud-desktop-business-const';
|
||||
|
||||
const createBusinessFeaturesBootstrapScript = () =>
|
||||
`globalThis[${JSON.stringify(CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG)}] = true;`;
|
||||
|
||||
const replaceBusinessFlagExport = (code: string, name: string, initializer: string) => {
|
||||
const pattern = new RegExp(`export\\s+(?:const|let|var)\\s+${name}\\s*=\\s*[\\s\\S]*?;`);
|
||||
|
||||
return {
|
||||
code: code.replace(pattern, `export let ${name} = ${initializer};`),
|
||||
replaced: pattern.test(code),
|
||||
};
|
||||
};
|
||||
|
||||
const injectDynamicBusinessFeatureFlag = (code: string) => {
|
||||
const businessFlag = replaceBusinessFlagExport(
|
||||
code,
|
||||
'ENABLE_BUSINESS_FEATURES',
|
||||
`Boolean(globalThis['${CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG}'])`,
|
||||
);
|
||||
const topicLinkFlag = replaceBusinessFlagExport(
|
||||
businessFlag.code,
|
||||
'ENABLE_TOPIC_LINK_SHARE',
|
||||
'ENABLE_BUSINESS_FEATURES',
|
||||
);
|
||||
|
||||
if (!businessFlag.replaced) {
|
||||
throw new Error('Cannot find ENABLE_BUSINESS_FEATURES export in @cloud/business-const');
|
||||
}
|
||||
|
||||
const topicLinkAssignment = topicLinkFlag.replaced
|
||||
? '\n ENABLE_TOPIC_LINK_SHARE = enabled;'
|
||||
: '';
|
||||
|
||||
return `${topicLinkFlag.code}
|
||||
|
||||
const __lobeCloudDesktopBusinessFeaturesFlagKey = '${CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG}';
|
||||
const __lobeCloudDesktopApplyBusinessFeaturesFlag = (value) => {
|
||||
const enabled = Boolean(value);
|
||||
ENABLE_BUSINESS_FEATURES = enabled;${topicLinkAssignment}
|
||||
return enabled;
|
||||
};
|
||||
|
||||
const __lobeCloudDesktopExistingDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
__lobeCloudDesktopBusinessFeaturesFlagKey,
|
||||
);
|
||||
const __lobeCloudDesktopInitialValue = __lobeCloudDesktopExistingDescriptor?.get
|
||||
? __lobeCloudDesktopExistingDescriptor.get.call(globalThis)
|
||||
: globalThis[__lobeCloudDesktopBusinessFeaturesFlagKey];
|
||||
|
||||
Object.defineProperty(globalThis, __lobeCloudDesktopBusinessFeaturesFlagKey, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return ENABLE_BUSINESS_FEATURES;
|
||||
},
|
||||
set(value) {
|
||||
__lobeCloudDesktopApplyBusinessFeaturesFlag(value);
|
||||
},
|
||||
});
|
||||
|
||||
__lobeCloudDesktopApplyBusinessFeaturesFlag(__lobeCloudDesktopInitialValue);
|
||||
`;
|
||||
};
|
||||
|
||||
function cloudDesktopBusinessConstPlugin(): PluginOption {
|
||||
return {
|
||||
enforce: 'pre',
|
||||
async resolveId(id, importer) {
|
||||
if (id !== BUSINESS_CONST_MODULE_ID) return;
|
||||
|
||||
const resolved = await this.resolve(CLOUD_BUSINESS_CONST_MODULE_ID, importer, {
|
||||
skipSelf: true,
|
||||
});
|
||||
if (!resolved) throw new Error(`Cannot resolve ${CLOUD_BUSINESS_CONST_MODULE_ID}`);
|
||||
|
||||
return `${resolved.id}${DYNAMIC_BUSINESS_CONST_QUERY}`;
|
||||
},
|
||||
load(id) {
|
||||
if (!id.endsWith(DYNAMIC_BUSINESS_CONST_QUERY)) return;
|
||||
|
||||
const sourcePath = id.slice(0, -DYNAMIC_BUSINESS_CONST_QUERY.length);
|
||||
return injectDynamicBusinessFeatureFlag(readFileSync(sourcePath, 'utf8'));
|
||||
},
|
||||
name: 'lobe-cloud-desktop-business-const',
|
||||
transformIndexHtml() {
|
||||
return [
|
||||
{
|
||||
children: createBusinessFeaturesBootstrapScript(),
|
||||
injectTo: 'head-prepend',
|
||||
tag: 'script',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const ROOT_DIR = path.resolve(__dirname, '../..');
|
||||
const CLOUD_ROOT_DIR = path.resolve(__dirname, '../../..');
|
||||
const isCloudDesktopBuild = process.env.CLOUD_DESKTOP === '1';
|
||||
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||
|
||||
Object.assign(process.env, loadEnv(mode, ROOT_DIR, ''));
|
||||
@@ -105,8 +208,17 @@ const mainProcessRuntimeExternals = [
|
||||
...externalRuntimeModules,
|
||||
'node-mac-permissions',
|
||||
];
|
||||
const externalNavigationHosts =
|
||||
process.env.DESKTOP_EXTERNAL_NAVIGATION_HOSTS ?? (isCloudDesktopBuild ? 'stripe.com' : '');
|
||||
|
||||
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
|
||||
console.info(`[electron-vite.config.ts] Cloud desktop build: ${isCloudDesktopBuild}`);
|
||||
|
||||
const cloudTsconfigPathsPlugin = () =>
|
||||
({
|
||||
...tsconfigPaths({ projects: [path.resolve(CLOUD_ROOT_DIR, 'tsconfig.json')] }),
|
||||
name: 'lobe-cloud-desktop-tsconfig-paths',
|
||||
}) satisfies PluginOption;
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
@@ -169,6 +281,7 @@ export default defineConfig({
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
define: {
|
||||
'process.env.DESKTOP_EXTERNAL_NAVIGATION_HOSTS': JSON.stringify(externalNavigationHosts),
|
||||
'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL),
|
||||
'process.env.UPDATE_SERVER_URL': JSON.stringify(process.env.UPDATE_SERVER_URL),
|
||||
},
|
||||
@@ -214,6 +327,8 @@ export default defineConfig({
|
||||
},
|
||||
optimizeDeps: sharedOptimizeDeps,
|
||||
plugins: [
|
||||
isCloudDesktopBuild && cloudTsconfigPathsPlugin(),
|
||||
isCloudDesktopBuild && cloudDesktopBusinessConstPlugin(),
|
||||
forceAbsoluteBasePlugin(),
|
||||
electronDesktopHtmlPlugin(),
|
||||
vanillaExtractPlugin(),
|
||||
@@ -221,7 +336,7 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
tsconfigPaths: true,
|
||||
tsconfigPaths: !isCloudDesktopBuild,
|
||||
},
|
||||
// In dev the BrowserWindow loads `app://renderer/` and the Electron main process
|
||||
// proxies non-backend requests to this Vite dev server via `net.fetch`. The HMR
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDesktopEnv } from '@/env';
|
||||
export const isDev = electronIs.dev();
|
||||
|
||||
export const OFFICIAL_CLOUD_SERVER = getDesktopEnv().OFFICIAL_CLOUD_SERVER;
|
||||
export const DESKTOP_EXTERNAL_NAVIGATION_HOSTS = getDesktopEnv().DESKTOP_EXTERNAL_NAVIGATION_HOSTS;
|
||||
|
||||
export const isMac = electronIs.macOS();
|
||||
export const isWindows = electronIs.windows();
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { BrowserWindowConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
|
||||
|
||||
import { preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isMac } from '@/const/env';
|
||||
import { DESKTOP_EXTERNAL_NAVIGATION_HOSTS, isMac } from '@/const/env';
|
||||
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
|
||||
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
|
||||
import { appendVercelCookie, setResponseHeader } from '@/utils/http-headers';
|
||||
@@ -19,6 +19,31 @@ import { WindowThemeManager } from './WindowThemeManager';
|
||||
|
||||
const logger = createLogger('core:Browser');
|
||||
|
||||
const getExternalNavigationHosts = () =>
|
||||
DESKTOP_EXTERNAL_NAVIGATION_HOSTS.split(',')
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const shouldOpenTopLevelNavigationExternally = (rawUrl: string) => {
|
||||
const externalNavigationHosts = getExternalNavigationHosts();
|
||||
if (externalNavigationHosts.length === 0) return false;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
return externalNavigationHosts.some(
|
||||
(externalHost) => hostname === externalHost || hostname.endsWith(`.${externalHost}`),
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
@@ -194,10 +219,26 @@ export default class Browser {
|
||||
this.setupReadyToShowListener(browserWindow);
|
||||
this.setupCloseListener(browserWindow);
|
||||
this.setupFocusListener(browserWindow);
|
||||
this.setupTopLevelNavigationListener(browserWindow);
|
||||
this.setupWillPreventUnloadListener(browserWindow);
|
||||
this.setupContextMenu(browserWindow);
|
||||
}
|
||||
|
||||
private setupTopLevelNavigationListener(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] Setting up top-level navigation listener.`);
|
||||
|
||||
browserWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (!shouldOpenTopLevelNavigationExternally(url)) return;
|
||||
|
||||
logger.info(`[${this.identifier}] Opening top-level navigation externally: ${url}`);
|
||||
event.preventDefault();
|
||||
|
||||
shell.openExternal(url).catch((error) => {
|
||||
logger.error(`[${this.identifier}] Failed to open external navigation URL: ${url}`, error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup window open handler to intercept external links
|
||||
* Prevents opening new windows in renderer and uses system browser instead
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
mockBrowserWindow,
|
||||
mockNativeTheme,
|
||||
mockIpcMain,
|
||||
mockShell,
|
||||
mockScreen,
|
||||
MockBrowserWindow,
|
||||
mockEnv,
|
||||
@@ -64,6 +65,7 @@ const {
|
||||
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
|
||||
mockBrowserWindow,
|
||||
mockEnv: {
|
||||
externalNavigationHosts: '',
|
||||
isDev: false,
|
||||
isLinux: false,
|
||||
isMac: false,
|
||||
@@ -91,6 +93,9 @@ const {
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
},
|
||||
mockShell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -101,6 +106,7 @@ vi.mock('electron', () => ({
|
||||
ipcMain: mockIpcMain,
|
||||
nativeTheme: mockNativeTheme,
|
||||
screen: mockScreen,
|
||||
shell: mockShell,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
@@ -121,6 +127,9 @@ vi.mock('@/const/dir', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
get DESKTOP_EXTERNAL_NAVIGATION_HOSTS() {
|
||||
return mockEnv.externalNavigationHosts;
|
||||
},
|
||||
get isDev() {
|
||||
return mockEnv.isDev;
|
||||
},
|
||||
@@ -182,6 +191,7 @@ describe('Browser', () => {
|
||||
mockEnv.isMac = false;
|
||||
mockEnv.isMacTahoe = false;
|
||||
mockEnv.isWindows = true;
|
||||
mockEnv.externalNavigationHosts = '';
|
||||
|
||||
// Create mock App
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
|
||||
@@ -730,4 +740,38 @@ describe('Browser', () => {
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('top-level navigation handling', () => {
|
||||
let willNavigateHandler: (event: any, url: string) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
willNavigateHandler = mockBrowserWindow.webContents.on.mock.calls.find(
|
||||
(call) => call[0] === 'will-navigate',
|
||||
)?.[1];
|
||||
});
|
||||
|
||||
it('should open configured external navigation hosts in system browser', () => {
|
||||
mockEnv.externalNavigationHosts = 'stripe.com';
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
expect(willNavigateHandler).toBeDefined();
|
||||
willNavigateHandler(mockEvent, 'https://checkout.stripe.com/c/pay/session_id');
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockShell.openExternal).toHaveBeenCalledWith(
|
||||
'https://checkout.stripe.com/c/pay/session_id',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow internal result routes in the app window', () => {
|
||||
mockEnv.externalNavigationHosts = 'stripe.com';
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
expect(willNavigateHandler).toBeDefined();
|
||||
willNavigateHandler(mockEvent, 'http://localhost:3000/payment/upgrade-success');
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(mockShell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,13 @@ const envNumber = (defaultValue: number) =>
|
||||
}, z.number().optional())
|
||||
.default(defaultValue);
|
||||
|
||||
const getRuntimeEnv = () => ({
|
||||
...process.env,
|
||||
DESKTOP_EXTERNAL_NAVIGATION_HOSTS: process.env.DESKTOP_EXTERNAL_NAVIGATION_HOSTS,
|
||||
UPDATE_CHANNEL: process.env.UPDATE_CHANNEL,
|
||||
UPDATE_SERVER_URL: process.env.UPDATE_SERVER_URL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Desktop (Electron main process) runtime env access.
|
||||
*
|
||||
@@ -63,13 +70,15 @@ export const getDesktopEnv = memoize(() =>
|
||||
clientPrefix: 'PUBLIC_',
|
||||
emptyStringAsUndefined: true,
|
||||
isServer: true,
|
||||
runtimeEnv: process.env,
|
||||
runtimeEnv: getRuntimeEnv(),
|
||||
server: {
|
||||
DEBUG_VERBOSE: envBoolean(false),
|
||||
|
||||
// escape hatch: allow testing static renderer in dev via env
|
||||
DESKTOP_RENDERER_STATIC: envBoolean(false),
|
||||
|
||||
DESKTOP_EXTERNAL_NAVIGATION_HOSTS: z.string().optional().default(''),
|
||||
|
||||
// device gateway url override (dev: point at a local `wrangler dev` instance,
|
||||
// e.g. http://localhost:8787). Falls back to the stored value, then the
|
||||
// production gateway.
|
||||
|
||||
Reference in New Issue
Block a user