diff --git a/.github/actions/desktop-build-setup/action.yml b/.github/actions/desktop-build-setup/action.yml index 30827aef95..259c7eacb1 100644 --- a/.github/actions/desktop-build-setup/action.yml +++ b/.github/actions/desktop-build-setup/action.yml @@ -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 diff --git a/.github/workflows/manual-build-desktop.yml b/.github/workflows/manual-build-desktop.yml index 1abbf82cec..539098f9b8 100644 --- a/.github/workflows/manual-build-desktop.yml +++ b/.github/workflows/manual-build-desktop.yml @@ -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 diff --git a/.github/workflows/pr-build-desktop.yml b/.github/workflows/pr-build-desktop.yml index bfceed9974..745f83d0ea 100644 --- a/.github/workflows/pr-build-desktop.yml +++ b/.github/workflows/pr-build-desktop.yml @@ -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 的版本号 diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 4a45e5a86d..13faafa0a0 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -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 diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml index 01e7a94536..9a4a3ba354 100644 --- a/.github/workflows/release-desktop-canary.yml +++ b/.github/workflows/release-desktop-canary.yml @@ -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 diff --git a/.github/workflows/release-desktop-stable.yml b/.github/workflows/release-desktop-stable.yml index 17fd5d40e1..9a5236b22d 100644 --- a/.github/workflows/release-desktop-stable.yml +++ b/.github/workflows/release-desktop-stable.yml @@ -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 diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 62730ad4a1..53c7a5a37c 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -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, }, }, }); diff --git a/apps/desktop/src/main/const/env.ts b/apps/desktop/src/main/const/env.ts index 4b204bb2e4..5b259b3293 100644 --- a/apps/desktop/src/main/const/env.ts +++ b/apps/desktop/src/main/const/env.ts @@ -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(); diff --git a/apps/desktop/src/main/core/browser/Browser.ts b/apps/desktop/src/main/core/browser/Browser.ts index 6c0ef774c3..6c1734e6a0 100644 --- a/apps/desktop/src/main/core/browser/Browser.ts +++ b/apps/desktop/src/main/core/browser/Browser.ts @@ -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 diff --git a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts index 8649eaa811..b0a8f9ac56 100644 --- a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +++ b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts @@ -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(); + }); + }); }); diff --git a/apps/desktop/src/main/env.ts b/apps/desktop/src/main/env.ts index 11440c13ef..1c36ef9f74 100644 --- a/apps/desktop/src/main/env.ts +++ b/apps/desktop/src/main/env.ts @@ -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), diff --git a/src/store/serverConfig/action.ts b/src/store/serverConfig/action.ts index f434a1590b..c2eca0a561 100644 --- a/src/store/serverConfig/action.ts +++ b/src/store/serverConfig/action.ts @@ -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)[ + CLOUD_DESKTOP_BUSINESS_FEATURES_FLAG + ] = Boolean(enableBusinessFeatures); +}; type Setter = StoreSetter; 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,