feat(desktop): restore cloud desktop builds (#15666)

This commit is contained in:
Innei
2026-06-11 19:14:26 +08:00
committed by GitHub
parent ecfdac5395
commit dbc8d76c8d
12 changed files with 318 additions and 5 deletions
+116 -1
View File
@@ -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
+1
View File
@@ -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();
+42 -1
View File
@@ -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();
});
});
});
+10 -1
View File
@@ -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.