feat(desktop): support cloud desktop builds (#14498)

*  feat(desktop): support cloud desktop builds

* 🐛 fix: open payment navigations externally in desktop
This commit is contained in:
Innei
2026-05-27 16:22:48 +08:00
committed by GitHub
parent c8ff3ac43d
commit 0c5ccc8770
12 changed files with 318 additions and 5 deletions
+89 -2
View File
@@ -5,6 +5,18 @@ inputs:
node-version:
description: Node.js version
required: true
cloud-repository:
description: Cloud repository to overlay for commercial desktop builds
required: false
default: lobehub/lobehub-cloud
cloud-ref:
description: Optional Cloud repository ref
required: false
default: ''
cloud-token:
description: GitHub token with permission to read the Cloud repository
required: false
default: ''
runs:
using: composite
@@ -14,9 +26,77 @@ runs:
with:
node-version: ${{ inputs.node-version }}
- name: Overlay Cloud repository for desktop build
if: inputs.cloud-token != ''
shell: bash
env:
CLOUD_CHECKOUT: ${{ runner.temp }}/lobehub-cloud
CLOUD_REF: ${{ inputs.cloud-ref }}
CLOUD_REPOSITORY: ${{ inputs.cloud-repository }}
CLOUD_ROOT: ${{ github.workspace }}/..
CLOUD_TOKEN: ${{ inputs.cloud-token }}
run: |
set -euo pipefail
cloud_root="$(cd "$GITHUB_WORKSPACE/.." && pwd)"
cloud_checkout="$RUNNER_TEMP/lobehub-cloud"
rm -rf "$cloud_checkout"
clone_args=(--depth 1)
if [ -n "$CLOUD_REF" ]; then
clone_args+=(--branch "$CLOUD_REF")
fi
git clone "${clone_args[@]}" "https://x-access-token:${CLOUD_TOKEN}@github.com/${CLOUD_REPOSITORY}.git" "$cloud_checkout"
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const source = process.env.CLOUD_CHECKOUT;
const target = process.env.CLOUD_ROOT;
const skip = new Set(['.git', 'lobehub', 'node_modules']);
const copy = (from, to) => {
const stat = fs.lstatSync(from);
if (stat.isSymbolicLink()) {
const link = fs.readlinkSync(from);
fs.rmSync(to, { force: true, recursive: true });
fs.symlinkSync(link, to);
return;
}
if (stat.isDirectory()) {
fs.mkdirSync(to, { recursive: true });
for (const entry of fs.readdirSync(from)) {
if (skip.has(entry)) continue;
copy(path.join(from, entry), path.join(to, entry));
}
return;
}
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(from, to);
};
for (const entry of fs.readdirSync(source)) {
if (skip.has(entry)) continue;
copy(path.join(source, entry), path.join(target, entry));
}
NODE
echo "CLOUD_DESKTOP=1" >> "$GITHUB_ENV"
echo "✅ Cloud repository overlaid at $cloud_root"
- name: Install dependencies
shell: bash
run: pnpm install --node-linker=hoisted
run: |
set -euo pipefail
if [ "${CLOUD_DESKTOP:-}" = "1" ]; then
cd ..
fi
pnpm install --node-linker=hoisted
# 移除国内 electron 镜像配置,GitHub Actions 使用官方源更快
- name: Remove China electron mirror from .npmrc
@@ -31,4 +111,11 @@ runs:
- name: Install deps on Desktop
shell: bash
run: npm run install-isolated --prefix=./apps/desktop
run: |
set -euo pipefail
if [ "${CLOUD_DESKTOP:-}" = "1" ]; then
cd ..
npm run install-isolated --prefix=./lobehub/apps/desktop
else
npm run install-isolated --prefix=./apps/desktop
fi
@@ -104,6 +104,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
@@ -172,6 +173,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
@@ -216,6 +218,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
+1
View File
@@ -92,6 +92,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: 24.11.1
# 设置 package.json 的版本号
@@ -87,6 +87,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
@@ -223,6 +223,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
@@ -180,6 +180,7 @@ jobs:
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
cloud-token: ${{ secrets.LOBEHUB_CLOUD_TOKEN }}
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
+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,
},
},
});
+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 { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
@@ -20,6 +20,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 {
@@ -195,10 +220,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
@@ -124,6 +130,9 @@ vi.mock('@/const/env', () => ({
get isDev() {
return mockEnv.isDev;
},
get DESKTOP_EXTERNAL_NAVIGATION_HOSTS() {
return mockEnv.externalNavigationHosts;
},
get isLinux() {
return mockEnv.isLinux;
},
@@ -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(''),
// Force use dev-app-update.yml even in packaged app (for testing updates)
FORCE_DEV_UPDATE_CONFIG: envBoolean(false),
+9
View File
@@ -8,6 +8,13 @@ import { type GlobalRuntimeConfig } from '@/types/serverConfig';
import { type ServerConfigStore } from './store';
const FETCH_SERVER_CONFIG_KEY = 'FETCH_SERVER_CONFIG';
const CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG = '__LOBECLOUD_DESKTOP_BUSINESS_FEATURES__';
const setDesktopBusinessFeaturesFlag = (enableBusinessFeatures: boolean | undefined) => {
(globalThis as unknown as Record<string, boolean | undefined>)[
CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG
] = Boolean(enableBusinessFeatures);
};
type Setter = StoreSetter<ServerConfigStore>;
export const createServerConfigSlice = (
@@ -31,9 +38,11 @@ export class ServerConfigActionImpl {
() => globalService.getGlobalConfig(),
{
onError: () => {
setDesktopBusinessFeaturesFlag(false);
this.#set({ serverConfigInit: true }, false, 'initServerConfigFallback');
},
onSuccess: (data) => {
setDesktopBusinessFeaturesFlag(data.serverConfig.enableBusinessFeatures);
this.#set(
{
billboard: data.billboard ?? null,