import { readdirSync, readFileSync } from 'fs' import { createRequire } from 'node:module' import { join, resolve } from 'path' import { sentryVitePlugin } from '@sentry/vite-plugin' import tailwindcss from '@tailwindcss/vite' import { tanstackRouter } from '@tanstack/router-plugin/vite' import react from '@vitejs/plugin-react' import { CodeInspectorPlugin } from 'code-inspector-plugin' import { defineConfig } from 'electron-vite' import { visualizer } from 'rollup-plugin-visualizer' import type { Plugin } from 'vite' import { parse } from 'yaml' // Import attributes are not supported by the current Electron config loader. // import pkg from './package.json' assert { type: 'json' } import pkg from './package.json' import { buildFlatContractCss } from './packages/ui/scripts/build-theme-css' import { chunkExportGuardPlugin } from './scripts/checkChunkExports' import { claudeCodeBinaryManifest } from './scripts/claudeCodeBinaryManifest' import { uiContractPlugin } from './scripts/uiContract/vitePlugin' import { APP_EDITIONS, type AppEdition } from './src/shared/types/appEdition' import { parseReleaseHistory, validateCurrentReleaseHistory } from './src/shared/utils/releaseNotes' import { getSentryBuildContext } from './src/shared/utils/sentry' const { buildSelectionPanel, sourcePath: selectionPanelSource } = createRequire(import.meta.url)( './native/selection-panel/build' ) const { buildExternalApp, sourceFiles: externalAppSources } = createRequire(import.meta.url)( './native/external-app/build.cjs' ) type ElectronBuilderConfig = { releaseInfo?: { releaseNotes?: unknown } } const electronBuilderConfig = parse( readFileSync(resolve(__dirname, 'electron-builder.yml'), 'utf8') ) as ElectronBuilderConfig const bundledReleaseNotes = electronBuilderConfig.releaseInfo?.releaseNotes const bundledReleaseHistory = parseReleaseHistory( readFileSync(resolve(__dirname, 'resources/cherry-studio/release-history.json'), 'utf8') ) if (typeof bundledReleaseNotes !== 'string' || !bundledReleaseNotes.trim()) { throw new Error('electron-builder.yml must define non-empty releaseInfo.releaseNotes') } validateCurrentReleaseHistory({ releaseNotes: bundledReleaseNotes, version: pkg.version }, bundledReleaseHistory) const visualizerPlugin = (type: 'renderer' | 'main') => { return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] } const isDev = process.env.NODE_ENV === 'development' const isProd = process.env.NODE_ENV === 'production' const SENTRY_UPLOAD_ENV_KEYS = ['SENTRY_AUTH_TOKEN', 'SENTRY_ORG', 'SENTRY_PROJECT'] as const export function resolveSentryBuildSettings(env: NodeJS.ProcessEnv) { const sourceMapUploadEnabled = env.NODE_ENV === 'production' && env.SENTRY_SOURCE_MAP_UPLOAD === 'true' const missingUploadEnv = sourceMapUploadEnabled ? SENTRY_UPLOAD_ENV_KEYS.filter((key) => !env[key]?.trim()) : [] if (missingUploadEnv.length > 0) { throw new Error(`Sentry production builds require: ${missingUploadEnv.join(', ')}`) } return { sourceMapUploadEnabled } } export function resolveRendererEdition(value: string | undefined): AppEdition { const edition = value?.trim().toLowerCase() || 'global' if (APP_EDITIONS.includes(edition as AppEdition)) return edition as AppEdition throw new Error(`Unsupported renderer edition: ${edition}`) } const rendererEdition = resolveRendererEdition(process.env.CHERRY_EDITION) const sentryBuildContext = getSentryBuildContext(pkg.name, pkg.version, rendererEdition) const { sourceMapUploadEnabled } = resolveSentryBuildSettings(process.env) const sentrySourceMap = sourceMapUploadEnabled ? ('hidden' as const) : isDev const sentrySourceMapPlugins = (outputDirectory: 'main' | 'preload' | 'renderer') => sourceMapUploadEnabled ? sentryVitePlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, telemetry: false, release: { name: sentryBuildContext.release, create: false, finalize: false, setCommits: false }, sourcemaps: { filesToDeleteAfterUpload: `./out/${outputDirectory}/**/*.map` } }) : [] // `dependencies` stay external and ship whole; everything else is bundled. Only R1–R4 packages // belong there: see "Main-Process Dependencies" in CLAUDE.md. const mainExternalDependencies = [ ...Object.keys(pkg.dependencies), // optionalDependencies too: platform-gated natives (e.g. node-mac-permissions) are real import // targets, not napi sub-packages, so rollup would fail on the .node; production keeps them installed. ...Object.keys(pkg.optionalDependencies ?? {}) ] export const mainExternalModules = ['bufferutil', 'utf-8-validate', 'electron', ...mainExternalDependencies] export const isMainExternalModule = (id: string) => { return mainExternalModules.some((moduleId) => id === moduleId || id.startsWith(`${moduleId}/`)) } // Ships the flat theme contract next to the main bundle so mini apps can be served // `assets/miniAppTheme.css`. Built in-process: nothing in the app pipeline runs the ui package build. const miniAppThemeAssetPlugin = (): Plugin => ({ name: 'cherry-mini-app-theme-asset', // The sources live outside the main bundle's module graph, so `electron-vite dev` would // serve a stale `miniAppTheme.css` after a token edit. Registered in `buildStart` because // `addWatchFile` is a build-phase call — it is not available from `generateBundle`. buildStart() { const stylesDir = resolve('packages/ui/src/styles') for (const name of readdirSync(stylesDir, { recursive: true, encoding: 'utf8' })) { if (name.endsWith('.css')) this.addWatchFile(join(stylesDir, name)) } }, async generateBundle() { let source: string try { source = await buildFlatContractCss() } catch (error) { return this.error(`failed to build assets/miniAppTheme.css: ${error instanceof Error ? error.message : error}`) } this.emitFile({ type: 'asset', fileName: 'assets/miniAppTheme.css', source }) } }) /** Shared with the utility-process entries build, which must resolve identically. */ export const mainResolveAlias = { '@main': resolve('src/main'), '@application': resolve('src/main/core/application/Application'), '@data': resolve('src/main/data'), '@shared': resolve('src/shared'), '@logger': resolve('src/main/core/logger/LoggerService'), '@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'), '@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'), '@cherrystudio/ai-core': resolve('packages/aiCore/src'), '@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'), '@cherrystudio/provider-registry/node': resolve('packages/provider-registry/src/registry-loader'), '@cherrystudio/provider-registry': resolve('packages/provider-registry/src'), '@cherrystudio/web-search/catalog': resolve('packages/web-search/src/catalog.ts'), '@cherrystudio/web-search/providers/zhipu': resolve('packages/web-search/src/providers/zhipu.ts'), '@cherrystudio/web-search/providers/tavily': resolve('packages/web-search/src/providers/tavily.ts'), '@cherrystudio/web-search/providers/searxng': resolve('packages/web-search/src/providers/searxng.ts'), '@cherrystudio/web-search/providers/exa': resolve('packages/web-search/src/providers/exa.ts'), '@cherrystudio/web-search/providers/exa-mcp': resolve('packages/web-search/src/providers/exaMcp.ts'), '@cherrystudio/web-search/providers/bocha': resolve('packages/web-search/src/providers/bocha.ts'), '@cherrystudio/web-search/providers/querit': resolve('packages/web-search/src/providers/querit.ts'), '@cherrystudio/web-search/providers/jina': resolve('packages/web-search/src/providers/jina.ts'), '@cherrystudio/web-search/providers/firecrawl': resolve('packages/web-search/src/providers/firecrawl.ts'), '@cherrystudio/web-search/providers/crawl4ai': resolve('packages/web-search/src/providers/crawl4ai.ts'), '@cherrystudio/web-search/providers/parallel': resolve('packages/web-search/src/providers/parallel.ts'), '@cherrystudio/web-search/providers/serply': resolve('packages/web-search/src/providers/serply.ts'), '@cherrystudio/web-search/providers/yuanbao': resolve('packages/web-search/src/providers/yuanbao.ts'), '@cherrystudio/web-search/providers/deepseek': resolve('packages/web-search/src/providers/deepseek.ts'), '@cherrystudio/web-search/providers/desearch': resolve('packages/web-search/src/providers/desearch.ts'), '@cherrystudio/web-search/providers/searchapi': resolve('packages/web-search/src/providers/searchapi.ts'), '@cherrystudio/web-search/tools/config': resolve('packages/web-search/src/tools/config.ts'), '@cherrystudio/web-search/tools/url-filter': resolve('packages/web-search/src/tools/urlFilter.ts'), '@cherrystudio/web-search/tools/content': resolve('packages/web-search/src/tools/content.ts'), '@cherrystudio/web-search': resolve('packages/web-search/src/index.ts'), '@test-mocks': resolve('tests/__mocks__'), '@test-helpers': resolve('tests/helpers') } export default defineConfig({ main: { define: { __APP_EDITION__: JSON.stringify(rendererEdition), __CLAUDE_CODE_BINARY_MANIFEST__: JSON.stringify(claudeCodeBinaryManifest(__dirname)) }, plugins: [ { name: 'cherry-selection-panel', buildStart() { this.addWatchFile(selectionPanelSource) buildSelectionPanel() } }, { name: 'cherry-external-app', apply: 'build', buildStart() { for (const file of externalAppSources) this.addWatchFile(file) buildExternalApp() } }, chunkExportGuardPlugin(), miniAppThemeAssetPlugin(), ...visualizerPlugin('main'), ...sentrySourceMapPlugins('main') ], resolve: { alias: [ ...Object.entries(mainResolveAlias).map(([find, replacement]) => ({ find, replacement })), // file-stream-rotator requires Moment's callable CommonJS export, not its ESM namespace. { find: /^moment$/, replacement: 'moment/moment.js' } ] }, build: { externalizeDeps: { include: mainExternalModules }, lib: { entry: resolve(__dirname, 'src/main/main.ts') }, rolldownOptions: { output: { manualChunks: (id) => { // conf removes its containing file from require.cache; isolate it so the app entry stays cached. if (id.includes('/node_modules/conf/')) return 'electron-store-conf' // rolldown drops this chunk's named exports when it merges with a re-export-only // facade chunk, leaving createOpenAI undefined at runtime. Keep it alone. if (id.includes('/node_modules/@ai-sdk/openai/')) return 'ai-sdk-openai' return undefined }, comments: isProd ? { legal: false } : undefined }, onwarn(warning, warn) { if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return warn(warning) } }, sourcemap: sentrySourceMap }, optimizeDeps: { noDiscovery: isDev } }, preload: { plugins: [...sentrySourceMapPlugins('preload')], resolve: { alias: { '@shared': resolve('src/shared'), '@cherrystudio/web-search/catalog': resolve('packages/web-search/src/catalog.ts'), '@cherrystudio/web-search': resolve('packages/web-search/src/index.ts') } }, build: { externalizeDeps: false, isolatedEntries: true, sourcemap: sentrySourceMap, rolldownOptions: { // Unlike renderer which auto-discovers entries from HTML files, // preload requires explicit entry point configuration for multiple scripts input: { preload: resolve(__dirname, 'src/preload/preload.ts'), simplest: resolve(__dirname, 'src/preload/simplest.ts'), // Minimal preload webview: resolve(__dirname, 'src/preload/webview.ts'), // Site `` guests miniAppBridge: resolve(__dirname, 'src/preload/miniAppBridge.ts') // Local mini app guests (`window.cherry`) }, external: ['electron'], output: { entryFileNames: '[name].js', format: 'cjs' } } } }, renderer: { define: { __APP_EDITION__: JSON.stringify(rendererEdition), __APP_RELEASE_HISTORY__: JSON.stringify(bundledReleaseHistory), __APP_RELEASE_NOTES__: JSON.stringify(bundledReleaseNotes), __APP_RELEASE_VERSION__: JSON.stringify(pkg.version) }, plugins: [ uiContractPlugin(), tanstackRouter({ target: 'react', autoCodeSplitting: true, routesDirectory: resolve('src/renderer/routes'), generatedRouteTree: resolve('src/renderer/routeTree.gen.ts') }), tailwindcss(), react(), ...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin ...visualizerPlugin('renderer'), ...sentrySourceMapPlugins('renderer') ], resolve: { alias: { '@renderer': resolve('src/renderer'), '@shared': resolve('src/shared'), '@logger': resolve('src/renderer/services/LoggerService'), '@data': resolve('src/renderer/data'), '@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'), '@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'), '@cherrystudio/ai-core': resolve('packages/aiCore/src'), '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'), '@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'), '@cherrystudio/provider-registry/node': resolve('packages/provider-registry/src/registry-loader'), '@cherrystudio/provider-registry': resolve('packages/provider-registry/src'), '@cherrystudio/web-search/catalog': resolve('packages/web-search/src/catalog.ts'), '@cherrystudio/web-search/providers/zhipu': resolve('packages/web-search/src/providers/zhipu.ts'), '@cherrystudio/web-search/providers/tavily': resolve('packages/web-search/src/providers/tavily.ts'), '@cherrystudio/web-search/providers/searxng': resolve('packages/web-search/src/providers/searxng.ts'), '@cherrystudio/web-search/providers/exa': resolve('packages/web-search/src/providers/exa.ts'), '@cherrystudio/web-search/providers/exa-mcp': resolve('packages/web-search/src/providers/exaMcp.ts'), '@cherrystudio/web-search/providers/bocha': resolve('packages/web-search/src/providers/bocha.ts'), '@cherrystudio/web-search/providers/querit': resolve('packages/web-search/src/providers/querit.ts'), '@cherrystudio/web-search/providers/jina': resolve('packages/web-search/src/providers/jina.ts'), '@cherrystudio/web-search/providers/firecrawl': resolve('packages/web-search/src/providers/firecrawl.ts'), '@cherrystudio/web-search/providers/crawl4ai': resolve('packages/web-search/src/providers/crawl4ai.ts'), '@cherrystudio/web-search/providers/parallel': resolve('packages/web-search/src/providers/parallel.ts'), '@cherrystudio/web-search/providers/serply': resolve('packages/web-search/src/providers/serply.ts'), '@cherrystudio/web-search/providers/deepseek': resolve('packages/web-search/src/providers/deepseek.ts'), '@cherrystudio/web-search/providers/desearch': resolve('packages/web-search/src/providers/desearch.ts'), '@cherrystudio/web-search/providers/searchapi': resolve('packages/web-search/src/providers/searchapi.ts'), '@cherrystudio/web-search/tools/config': resolve('packages/web-search/src/tools/config.ts'), '@cherrystudio/web-search/tools/url-filter': resolve('packages/web-search/src/tools/urlFilter.ts'), '@cherrystudio/web-search/tools/content': resolve('packages/web-search/src/tools/content.ts'), '@cherrystudio/web-search': resolve('packages/web-search/src/index.ts'), '@cherrystudio/file-preview/core': resolve('packages/file-preview/src/core.ts'), '@cherrystudio/file-preview/react': resolve('packages/file-preview/src/react.ts'), '@cherrystudio/ui/icons/providers': resolve('packages/ui/src/components/icons/providers'), '@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'), '@cherrystudio/ui': resolve('packages/ui/src'), '@test-mocks': resolve('tests/__mocks__') } }, optimizeDeps: { exclude: ['pyodide'], include: ['pdfjs-dist/build/pdf.worker.mjs'], rolldownOptions: { transform: { target: 'esnext' // for dev } } }, worker: { format: 'es' }, build: { sourcemap: sentrySourceMap, target: 'esnext', // for build rolldownOptions: { input: { index: resolve(__dirname, 'src/renderer/windows/main/index.html'), quickAssistant: resolve(__dirname, 'src/renderer/windows/quickAssistant/index.html'), selectionToolbar: resolve(__dirname, 'src/renderer/windows/selection/toolbar/index.html'), selectionAction: resolve(__dirname, 'src/renderer/windows/selection/action/index.html'), selectionAgent: resolve(__dirname, 'src/renderer/windows/selection/agent/index.html'), selectionCapsule: resolve(__dirname, 'src/renderer/windows/selection/capsule/index.html'), migrationV2: resolve(__dirname, 'src/renderer/windows/migrationV2/index.html'), userDataRelocation: resolve(__dirname, 'src/renderer/windows/userDataRelocation/index.html'), subWindow: resolve(__dirname, 'src/renderer/windows/subWindow/index.html'), screenshot: resolve(__dirname, 'src/renderer/windows/screenshot/index.html') }, onwarn(warning, warn) { if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return warn(warning) }, output: { comments: isProd ? { legal: false } : undefined, advancedChunks: { // Without this, groups recursively capture dependencies — React // itself ends up inside an icon bucket and every window preloads it. includeDependenciesRecursively: false, groups: [ // Bucket per-icon lazy modules into mid-size chunks instead of one // tiny chunk per icon. Model icons only: they are reached solely // through the dynamic loaders, so the buckets stay off every // window's eager graph. Provider icons must NOT be grouped — a few // files statically import specific providers from // @cherrystudio/ui/icons/providers, and bucketing would chain // whole buckets of unrelated SVGs into those windows' first load. // Only the SVG component files (*.tsx) may match: each icon dir's // meta.ts is eagerly imported by the meta-catalogs. { name: 'icons-models', test: /packages\/ui\/src\/components\/icons\/models\/[^/]+\/(?:index|light|dark|avatar)\.tsx$/, maxSize: 150_000 } ] } } } } } })